Browse Source

Feature/add tags support in accounts (#7242)

* Add tags support in accounts

* Update changelog
pull/7246/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
79e382a8f7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 3
      apps/api/src/app/account-balance/account-balance.service.ts
  3. 40
      apps/api/src/app/account/account.controller.ts
  4. 109
      apps/api/src/app/account/account.service.ts
  5. 56
      apps/api/src/app/activities/activities.service.ts
  6. 2
      apps/api/src/app/endpoints/tags/tags.controller.ts
  7. 16
      apps/api/src/app/export/export.service.ts
  8. 126
      apps/api/src/app/import/import.service.ts
  9. 13
      apps/api/src/app/portfolio/portfolio.service.ts
  10. 12
      apps/api/src/helper/account.helper.ts
  11. 16
      apps/api/src/services/tag/tag.service.ts
  12. 20
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  13. 1
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  14. 15
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  15. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  16. 19
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  17. 90
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  18. 9
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  19. 6
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts
  20. 11
      libs/common/src/lib/dtos/create-account.dto.ts
  21. 11
      libs/common/src/lib/dtos/update-account.dto.ts
  22. 15
      libs/common/src/lib/helper.ts
  23. 1
      libs/common/src/lib/interfaces/responses/export-response.interface.ts
  24. 7
      libs/common/src/lib/types/account-with-platform.type.ts
  25. 3
      libs/common/src/lib/types/account-with-value.type.ts
  26. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  27. 10
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  28. 6
      libs/ui/src/lib/activities-table/activities-table.component.ts
  29. 18
      libs/ui/src/lib/tags-selector/tags-selector.component.html
  30. 25
      libs/ui/src/lib/tags-selector/tags-selector.component.ts
  31. 20
      prisma/migrations/20260705095904_added_tags_on_accounts/migration.sql
  32. 16
      prisma/schema.prisma

3
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support for tags in the account (experimental)
- Exposed the `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL` environment variable to control the removal of failed jobs in the portfolio snapshot computation queue - Exposed the `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_REMOVE_ON_FAIL` environment variable to control the removal of failed jobs in the portfolio snapshot computation queue
### Changed ### Changed
@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Set the change detection strategy to `OnPush` in the prompt dialog component - Set the change detection strategy to `OnPush` in the prompt dialog component
- Set the change detection strategy to `OnPush` in the overview of the admin control panel - Set the change detection strategy to `OnPush` in the overview of the admin control panel
- Set the change detection strategy to `OnPush` in the portfolio page - Set the change detection strategy to `OnPush` in the portfolio page
- Deprecated the `isExcluded` attribute of the account in favor of the _Exclude from Analysis_ tag
- Improved the language localization in the users table of the admin control panel - Improved the language localization in the users table of the admin control panel
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
- Upgraded `envalid` from version `8.1.1` to `8.2.0` - Upgraded `envalid` from version `8.1.1` to `8.2.0`
@ -25,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue with the custom tags of the user in the import functionality
- Fixed the creation of the _Stripe_ checkout session for languages not supported by _Stripe_ (`ca` and `uk`) - Fixed the creation of the _Stripe_ checkout session for languages not supported by _Stripe_ (`ca` and `uk`)
- Fixed the error handling in the endpoint to create a _Stripe_ checkout session - Fixed the error handling in the endpoint to create a _Stripe_ checkout session

3
apps/api/src/app/account-balance/account-balance.service.ts

@ -1,4 +1,5 @@
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
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';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -154,7 +155,7 @@ export class AccountBalanceService {
} }
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.account = { isExcluded: false }; where.account = WHERE_ACCOUNT_NOT_EXCLUDED;
} }
const balances = await this.prismaService.accountBalance.findMany({ const balances = await this.prismaService.accountBalance.findMany({

40
apps/api/src/app/account/account.controller.ts

@ -156,27 +156,31 @@ export class AccountController {
public async createAccount( public async createAccount(
@Body() data: CreateAccountDto @Body() data: CreateAccountDto
): Promise<AccountModel> { ): Promise<AccountModel> {
if (data.platformId) { const { tags: tagIds, ...accountData } = data;
const platformId = data.platformId;
delete data.platformId; if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...accountData,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id,
tagIds
); );
} else { } else {
delete data.platformId; delete accountData.platformId;
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...accountData,
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id,
tagIds
); );
} }
} }
@ -253,14 +257,16 @@ export class AccountController {
); );
} }
if (data.platformId) { const { tags: tagIds, ...accountData } = data;
const platformId = data.platformId;
delete data.platformId; if (accountData.platformId) {
const platformId = accountData.platformId;
delete accountData.platformId;
return this.accountService.updateAccount( return this.accountService.updateAccount(
{ {
data: { data: {
...data, ...accountData,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
@ -271,16 +277,17 @@ export class AccountController {
} }
} }
}, },
this.request.user.id this.request.user.id,
tagIds
); );
} else { } else {
// platformId is null, remove it // platformId is null, remove it
delete data.platformId; delete accountData.platformId;
return this.accountService.updateAccount( return this.accountService.updateAccount(
{ {
data: { data: {
...data, ...accountData,
platform: originalAccount.platformId platform: originalAccount.platformId
? { disconnect: true } ? { disconnect: true }
: undefined, : undefined,
@ -293,7 +300,8 @@ export class AccountController {
} }
} }
}, },
this.request.user.id this.request.user.id,
tagIds
); );
} }
} }

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

@ -1,11 +1,12 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.helper';
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';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { import {
Account, Account,
@ -13,10 +14,12 @@ import {
Order, Order,
Platform, Platform,
Prisma, Prisma,
SymbolProfile SymbolProfile,
Tag
} from '@prisma/client'; } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -66,17 +69,27 @@ export class AccountService {
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; activities?: (Order & { SymbolProfile?: SymbolProfile })[];
balances?: AccountBalance[]; balances?: AccountBalance[];
platform?: Platform; platform?: Platform;
tags?: Tag[];
})[] })[]
> { > {
const { include = {}, skip, take, cursor, where, orderBy } = params; const { include = {}, skip, take, cursor, where, orderBy } = params;
const isBalancesIncluded = !!include.balances; const isBalancesIncluded = !!include.balances;
const isTagsIncluded = !!include.tags;
include.balances = { include.balances = {
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 }) ...(isBalancesIncluded ? {} : { take: 1 })
}; };
if (isTagsIncluded) {
include.tags = {
include: {
tag: true
}
};
}
const accounts = await this.prismaService.account.findMany({ const accounts = await this.prismaService.account.findMany({
cursor, cursor,
include, include,
@ -87,22 +100,48 @@ export class AccountService {
}); });
return accounts.map((account) => { return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 }; const result = {
...account,
balance: account.balances[0]?.value ?? 0,
tags: isTagsIncluded
? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag;
})
: undefined
};
if (!isBalancesIncluded) { if (!isBalancesIncluded) {
delete account.balances; delete result.balances;
} }
return account; if (!isTagsIncluded) {
delete result.tags;
}
return result;
}); });
} }
public async createAccount( public async createAccount(
data: Prisma.AccountCreateInput, data: Prisma.AccountCreateInput,
aUserId: string aUserId: string,
tagIds?: string[]
): Promise<Account> { ): Promise<Account> {
await this.validateTagIds(tagIds, aUserId);
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data data: {
...data,
tags: tagIds
? {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
})
}
: undefined
}
}); });
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
@ -143,7 +182,8 @@ export class AccountService {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { include: {
activities: true, activities: true,
platform: true platform: true,
tags: true
}, },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
where: { userId: aUserId } where: { userId: aUserId }
@ -184,7 +224,7 @@ export class AccountService {
}; };
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.isExcluded = false; where.AND = [WHERE_ACCOUNT_NOT_EXCLUDED];
} }
const { ACCOUNT: filtersByAccount = [] } = groupBy(filters, ({ type }) => { const { ACCOUNT: filtersByAccount = [] } = groupBy(filters, ({ type }) => {
@ -222,22 +262,37 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput; data: Prisma.AccountUpdateInput;
}, },
aUserId: string aUserId: string,
tagIds?: string[]
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
await this.validateTagIds(tagIds, aUserId);
const account = await this.prismaService.account.update({
data: {
...data,
tags: tagIds
? {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
}),
deleteMany: {}
}
: undefined
},
where
});
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: data.id as string, accountId: account.id,
balance: data.balance as number, balance: data.balance as number,
date: format(new Date(), DATE_FORMAT), date: format(new Date(), DATE_FORMAT),
userId: aUserId userId: aUserId
}); });
const account = await this.prismaService.account.update({
data,
where
});
this.eventEmitter.emit( this.eventEmitter.emit(
PortfolioChangedEvent.getName(), PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({ new PortfolioChangedEvent({
@ -285,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
);
}
}
} }

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

@ -3,6 +3,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { WHERE_ACCOUNT_NOT_EXCLUDED } from '@ghostfolio/api/helper/account.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';
@ -568,18 +569,15 @@ export class ActivitiesService {
{ date: 'asc' } { date: 'asc' }
]; ];
const where: Prisma.OrderWhereInput = { userId }; const andConditions: Prisma.OrderWhereInput[] = [];
const where: Prisma.OrderWhereInput = { userId, AND: andConditions };
if (endDate || startDate) {
where.AND = [];
if (endDate) { if (endDate) {
where.AND.push({ date: { lte: endDate } }); andConditions.push({ date: { lte: endDate } });
} }
if (startDate) { if (startDate) {
where.AND.push({ date: { gt: startDate } }); andConditions.push({ date: { gt: startDate } });
}
} }
const { const {
@ -682,13 +680,30 @@ export class ActivitiesService {
} }
if (filtersByTag.length > 0) { if (filtersByTag.length > 0) {
where.tags = { andConditions.push({
OR: [
{
tags: {
some: { some: {
OR: filtersByTag.map(({ id }) => { OR: filtersByTag.map(({ id }) => {
return { id }; return { id };
}) })
} }
}; }
},
{
account: {
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { tagId: id };
})
}
}
}
}
]
});
} }
if (sortColumn) { if (sortColumn) {
@ -700,13 +715,9 @@ export class ActivitiesService {
} }
if (withExcludedAccountsAndActivities === false) { if (withExcludedAccountsAndActivities === false) {
where.OR = [ where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }];
{ account: null },
{ account: { NOT: { isExcluded: true } } }
];
where.tags = { where.tags = {
...where.tags,
none: { none: {
id: TAG_ID_EXCLUDE_FROM_ANALYSIS id: TAG_ID_EXCLUDE_FROM_ANALYSIS
} }
@ -721,7 +732,12 @@ export class ActivitiesService {
include: { include: {
account: { account: {
include: { include: {
platform: true platform: true,
tags: {
include: {
tag: true
}
}
} }
}, },
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
@ -733,6 +749,16 @@ export class ActivitiesService {
this.prismaService.order.count({ where }) this.prismaService.order.count({ where })
]); ]);
for (const order of orders) {
if (order.account) {
order.account.tags = (
order.account.tags as unknown as { tag: Tag }[]
).map(({ tag }) => {
return tag;
});
}
}
const assetProfileIdentifiers = uniqBy( const assetProfileIdentifiers = uniqBy(
orders.map(({ SymbolProfile }) => { orders.map(({ SymbolProfile }) => {
return { return {

2
apps/api/src/app/endpoints/tags/tags.controller.ts

@ -83,7 +83,7 @@ export class TagsController {
@HasPermission(permissions.readTags) @HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() { public async getTags() {
return this.tagService.getTagsWithActivityCount(); return this.tagService.getTagsWithAccountAndActivityCount();
} }
@HasPermission(permissions.updateTag) @HasPermission(permissions.updateTag)

16
apps/api/src/app/export/export.service.ts

@ -72,7 +72,8 @@ export class ExportService {
where, where,
include: { include: {
balances: true, balances: true,
platform: true platform: true,
tags: true
}, },
orderBy: { orderBy: {
name: 'asc' name: 'asc'
@ -96,7 +97,8 @@ export class ExportService {
isExcluded, isExcluded,
name, name,
platform, platform,
platformId platformId,
tags
}) => { }) => {
if (platformId) { if (platformId) {
platformsMap[platformId] = platform; platformsMap[platformId] = platform;
@ -112,7 +114,10 @@ export class ExportService {
id, id,
isExcluded, isExcluded,
name, name,
platformId platformId,
tags: tags.map(({ id: tagId }) => {
return tagId;
})
}; };
} }
); );
@ -151,11 +156,14 @@ export class ExportService {
.filter(({ id, isUsed }) => { .filter(({ id, isUsed }) => {
return ( return (
isUsed && isUsed &&
(accounts.some(({ tags: tagIds }) => {
return tagIds.includes(id);
}) ||
activities.some((activity) => { activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => { return activity.tags.some(({ id: tagId }) => {
return tagId === id; return tagId === id;
}); });
}) }))
); );
}) })
.map(({ id, name }) => { .map(({ id, name }) => {

126
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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import { import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
CreateAssetProfileDto,
CreateAccountDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { import {
getAssetProfileIdentifier, getAssetProfileIdentifier,
parseDate parseDate
@ -194,6 +190,60 @@ export class ImportService {
const tagIdMapping: { [oldTagId: string]: string } = {}; const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = user.settings.settings.baseCurrency;
const existingTagsOfUser =
tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length)
? await this.tagService.getTagsForUser(user.id)
: [];
if (tagsDto?.length) {
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
existingTagsOfUser.push({
id: newTag.id,
isUsed: false,
name: newTag.name,
userId: newTag.userId
});
}
}
}
}
if (!isDryRun && accountsWithBalancesDto?.length) { if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([ const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({ this.accountService.accounts({
@ -208,6 +258,12 @@ export class ImportService {
this.platformService.getPlatforms() this.platformService.getPlatforms()
]); ]);
const existingTagIds = new Set(
existingTagsOfUser.map(({ id }) => {
return id;
})
);
for (const accountWithBalances of accountsWithBalancesDto) { for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID // Check if there is any existing account with the same ID
const accountWithSameId = existingAccounts.find((existingAccount) => { const accountWithSameId = existingAccounts.find((existingAccount) => {
@ -216,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 there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) { if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account: CreateAccountDto = omit( const account = omit(accountWithBalances, ['balances', 'tags']);
accountWithBalances,
'balances'
);
let oldAccountId: string; let oldAccountId: string;
const platformId = account.platformId; const platformId = account.platformId;
@ -231,6 +284,14 @@ export class ImportService {
delete account.id; delete account.id;
} }
const tagIds = (accountWithBalances.tags ?? [])
.map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
})
.filter((tagId) => {
return existingTagIds.has(tagId);
});
let accountObject: Prisma.AccountCreateInput = { let accountObject: Prisma.AccountCreateInput = {
...account, ...account,
balances: { balances: {
@ -252,7 +313,8 @@ export class ImportService {
const newAccount = await this.accountService.createAccount( const newAccount = await this.accountService.createAccount(
accountObject, accountObject,
user.id user.id,
tagIds
); );
// Store the new to old account ID mappings for updating activities // Store the new to old account ID mappings for updating activities
@ -320,50 +382,6 @@ export class ImportService {
} }
} }
if (tagsDto?.length) {
const existingTagsOfUser = await this.tagService.getTagsForUser(user.id);
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
);
for (const tag of tagsDto) {
const existingTagOfUser = existingTagsOfUser.find(({ id }) => {
return id === tag.id;
});
if (!existingTagOfUser || existingTagOfUser.userId !== null) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
);
}
if (!isDryRun) {
const existingTag = await this.tagService.getTag({ id: tag.id });
let oldTagId: string;
if (existingTag) {
oldTagId = tag.id;
delete tag.id;
}
const tagObject: Prisma.TagCreateInput = {
...tag,
user: { connect: { id: user.id } }
};
const newTag = await this.tagService.createTag(tagObject);
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
}
}
}
}
for (const activity of activitiesDto) { for (const activity of activitiesDto) {
if (!activity.dataSource) { if (!activity.dataSource) {
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) {

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

@ -41,6 +41,7 @@ import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getSum, getSum,
isAccountExcluded,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -169,7 +170,8 @@ export class PortfolioService {
where, where,
include: { include: {
activities: { include: { SymbolProfile: true } }, activities: { include: { SymbolProfile: true } },
platform: true platform: true,
tags: true
}, },
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
@ -1879,7 +1881,7 @@ export class PortfolioService {
for (const activity of activities) { for (const activity of activities) {
if ( if (
activity.account?.isExcluded || (activity.account && isAccountExcluded(activity.account)) ||
activity.tags?.some(({ id }) => { activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) })
@ -2123,13 +2125,14 @@ export class PortfolioService {
let currentAccounts: (Account & { let currentAccounts: (Account & {
Order?: Order[]; Order?: Order[];
platform?: Platform; platform?: Platform;
tags?: Tag[];
})[] = []; })[] = [];
if (filters.length === 0) { if (filters.length === 0) {
currentAccounts = await this.accountService.getAccounts(userId); currentAccounts = await this.accountService.getAccounts(userId);
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({
include: { platform: true }, include: { platform: true, tags: true },
where: { id: filters[0].id } where: { id: filters[0].id }
}); });
} else { } else {
@ -2146,13 +2149,13 @@ export class PortfolioService {
); );
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({
include: { platform: true }, include: { platform: true, tags: true },
where: { id: { in: accountIds } } where: { id: { in: accountIds } }
}); });
} }
currentAccounts = currentAccounts.filter((account) => { currentAccounts = currentAccounts.filter((account) => {
return withExcludedAccounts || account.isExcluded === false; return withExcludedAccounts || !isAccountExcluded(account);
}); });
// Iterate over the accounts plus a null entry to group activities without // Iterate over the accounts plus a null entry to group activities without

12
apps/api/src/helper/account.helper.ts

@ -0,0 +1,12 @@
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { Prisma } from '@prisma/client';
export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = {
isExcluded: false,
tags: {
none: {
tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS
}
}
};

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

@ -52,6 +52,11 @@ export class TagService {
include: { include: {
_count: { _count: {
select: { select: {
accounts: {
where: {
userId
}
},
activities: { activities: {
where: { where: {
userId userId
@ -80,27 +85,28 @@ export class TagService {
id, id,
name, name,
userId, userId,
isUsed: _count.activities > 0 isUsed: _count.accounts > 0 || _count.activities > 0
})) }))
.sort((a, b) => { .sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}); });
} }
public async getTagsWithActivityCount() { public async getTagsWithAccountAndActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({ const tagsWithAccountAndOrderCount = await this.prismaService.tag.findMany({
include: { include: {
_count: { _count: {
select: { activities: true } select: { accounts: true, activities: true }
} }
} }
}); });
return tagsWithOrderCount.map(({ _count, id, name, userId }) => { return tagsWithAccountAndOrderCount.map(({ _count, id, name, userId }) => {
return { return {
id, id,
name, name,
userId, userId,
accountCount: _count.accounts,
activityCount: _count.activities activityCount: _count.activities
}; };
}); });

20
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -35,6 +35,24 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="accounts">
<th
*matHeaderCellDef
class="justify-content-end px-1"
mat-header-cell
mat-sort-header="accountCount"
>
<ng-container i18n>Accounts</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[locale]="locale()"
[value]="element.accountCount"
/>
</td>
</ng-container>
<ng-container matColumnDef="activities"> <ng-container matColumnDef="activities">
<th <th
*matHeaderCellDef *matHeaderCellDef
@ -74,7 +92,7 @@
<hr class="m-0" /> <hr class="m-0" />
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activityCount > 0" [disabled]="element.accountCount > 0 || element.activityCount > 0"
(click)="onDeleteTag(element.id)" (click)="onDeleteTag(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">

1
apps/client/src/app/components/admin-tag/admin-tag.component.ts

@ -62,6 +62,7 @@ export class GfAdminTagComponent implements OnInit {
protected readonly displayedColumns = [ protected readonly displayedColumns = [
'name', 'name',
'userId', 'userId',
'accounts',
'activities', 'activities',
'actions' 'actions'
]; ];

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

@ -77,7 +77,7 @@ import {
swapVerticalOutline, swapVerticalOutline,
walletOutline walletOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { isNumber, round } from 'lodash'; import { isNumber, round, uniqBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
@ -179,6 +179,7 @@ export class GfHoldingDetailDialogComponent implements OnInit {
protected sortColumn = 'date'; protected sortColumn = 'date';
protected sortDirection: SortDirection = 'desc'; protected sortDirection: SortDirection = 'desc';
protected tagsAvailable: Tag[]; protected tagsAvailable: Tag[];
protected tagsOfAccounts: Tag[];
protected readonly translate = translate; protected readonly translate = translate;
protected user: User; protected user: User;
protected value: number; protected value: number;
@ -265,6 +266,18 @@ export class GfHoldingDetailDialogComponent implements OnInit {
.subscribe(({ accounts }) => { .subscribe(({ accounts }) => {
this.accounts = accounts; this.accounts = accounts;
this.tagsOfAccounts = uniqBy(
accounts.flatMap(({ tags }) => {
return tags ?? [];
}),
'id'
).map((tag) => {
return {
...tag,
name: translate(tag.name)
};
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

1
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -427,6 +427,7 @@
[hasPermissionToCreateTag]="hasPermissionToCreateOwnTag" [hasPermissionToCreateTag]="hasPermissionToCreateOwnTag"
[readonly]="!data.hasPermissionToUpdateActivity" [readonly]="!data.hasPermissionToUpdateActivity"
[tagsAvailable]="tagsAvailable" [tagsAvailable]="tagsAvailable"
[tagsReadOnly]="tagsOfAccounts"
/> />
</form> </form>

19
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -25,7 +25,7 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel, Tag } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -188,8 +188,9 @@ export class GfAccountsPageComponent implements OnInit {
id, id,
isExcluded, isExcluded,
name, name,
platformId platformId,
}: AccountModel) { tags
}: AccountModel & { tags?: Tag[] }) {
const dialogRef = this.dialog.open< const dialogRef = this.dialog.open<
GfCreateOrUpdateAccountDialogComponent, GfCreateOrUpdateAccountDialogComponent,
CreateOrUpdateAccountDialogParams CreateOrUpdateAccountDialogParams
@ -202,8 +203,10 @@ export class GfAccountsPageComponent implements OnInit {
id, id,
isExcluded, isExcluded,
name, name,
platformId platformId,
} tags
},
user: this.user
}, },
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
@ -277,8 +280,10 @@ export class GfAccountsPageComponent implements OnInit {
id: null, id: null,
isExcluded: false, isExcluded: false,
name: null, name: null,
platformId: null platformId: null,
} tags: []
},
user: this.user
} satisfies CreateOrUpdateAccountDialogParams, } satisfies CreateOrUpdateAccountDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'

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

@ -1,11 +1,22 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos'; import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { validateObjectForForm } from '@ghostfolio/common/utils'; import { validateObjectForForm } from '@ghostfolio/common/utils';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { translate } from '@ghostfolio/ui/i18n';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import {
ChangeDetectionStrategy,
Component,
DestroyRef,
inject
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
AbstractControl, AbstractControl,
FormBuilder, FormBuilder,
@ -24,7 +35,7 @@ import {
} from '@angular/material/dialog'; } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { Platform } from '@prisma/client'; import { Platform, Tag } from '@prisma/client';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators'; import { map, startWith } from 'rxjs/operators';
@ -37,6 +48,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
CommonModule, CommonModule,
GfCurrencySelectorComponent, GfCurrencySelectorComponent,
GfEntityLogoComponent, GfEntityLogoComponent,
GfTagsSelectorComponent,
MatAutocompleteModule, MatAutocompleteModule,
MatButtonModule, MatButtonModule,
MatCheckboxModule, MatCheckboxModule,
@ -53,19 +65,41 @@ export class GfCreateOrUpdateAccountDialogComponent {
protected accountForm: FormGroup; protected accountForm: FormGroup;
protected currencies: string[] = []; protected currencies: string[] = [];
protected filteredPlatforms: Observable<Platform[]> | undefined; protected filteredPlatforms: Observable<Platform[]> | undefined;
protected hasPermissionToCreateOwnTag: boolean | undefined;
protected platforms: Platform[] = []; protected platforms: Platform[] = [];
protected tagsAvailable: Tag[] = [];
protected readonly data = protected readonly data =
inject<CreateOrUpdateAccountDialogParams>(MAT_DIALOG_DATA); inject<CreateOrUpdateAccountDialogParams>(MAT_DIALOG_DATA);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef = private readonly dialogRef =
inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(MatDialogRef); inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(MatDialogRef);
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly userService = inject(UserService);
public ngOnInit() { public ngOnInit() {
const { currencies } = this.dataService.fetchInfo(); const { currencies } = this.dataService.fetchInfo();
this.currencies = currencies; this.currencies = currencies;
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.tagsAvailable = [
...(this.data.user?.tags ?? []),
{
id: TAG_ID_EXCLUDE_FROM_ANALYSIS,
name: 'EXCLUDE_FROM_ANALYSIS',
userId: null
}
].map((tag) => {
return {
...tag,
name: translate(tag.name)
};
});
this.accountForm = this.formBuilder.group({ this.accountForm = this.formBuilder.group({
accountId: [{ disabled: true, value: this.data.account.id }], accountId: [{ disabled: true, value: this.data.account.id }],
balance: [this.data.account.balance, Validators.required], balance: [this.data.account.balance, Validators.required],
@ -73,7 +107,46 @@ export class GfCreateOrUpdateAccountDialogComponent {
currency: [this.data.account.currency, Validators.required], currency: [this.data.account.currency, Validators.required],
isExcluded: [this.data.account.isExcluded], isExcluded: [this.data.account.isExcluded],
name: [this.data.account.name, Validators.required], name: [this.data.account.name, Validators.required],
platformId: [null, this.autocompleteObjectValidator()] platformId: [null, this.autocompleteObjectValidator()],
tags: [
this.data.account.tags?.map(({ id, name }) => {
return {
id,
name: translate(name)
};
})
]
});
this.accountForm
.get('tags')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tags: Tag[]) => {
const newTag = tags.find(({ id }) => {
return id === undefined;
});
if (newTag && this.hasPermissionToCreateOwnTag) {
this.dataService
.postTag({ ...newTag, userId: this.data.user.id })
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((tag) => {
this.accountForm.get('tags')?.setValue(
tags.map((currentTag) => {
if (currentTag.id === undefined) {
return tag;
}
return currentTag;
})
);
this.userService
.get(true)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe();
});
}
}); });
this.dataService.fetchPlatforms().subscribe(({ platforms }) => { this.dataService.fetchPlatforms().subscribe(({ platforms }) => {
@ -132,7 +205,16 @@ export class GfCreateOrUpdateAccountDialogComponent {
id: this.accountForm.get('accountId')?.value, id: this.accountForm.get('accountId')?.value,
isExcluded: this.accountForm.get('isExcluded')?.value, isExcluded: this.accountForm.get('isExcluded')?.value,
name: this.accountForm.get('name')?.value, name: this.accountForm.get('name')?.value,
platformId: this.accountForm.get('platformId')?.value?.id || null platformId: this.accountForm.get('platformId')?.value?.id || null,
tags: this.accountForm
.get('tags')
?.value?.filter(({ id }: Tag) => {
// Skip tags which have not been created yet
return !!id;
})
.map(({ id }: Tag) => {
return id;
})
}; };
try { try {

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

@ -85,6 +85,15 @@
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
@if (data.user?.settings?.isExperimentalFeatures) {
<div class="mb-3">
<gf-tags-selector
formControlName="tags"
[hasPermissionToCreateTag]="hasPermissionToCreateOwnTag"
[tagsAvailable]="tagsAvailable"
/>
</div>
}
<div class="mb-3 px-2"> <div class="mb-3 px-2">
<mat-checkbox color="primary" formControlName="isExcluded" i18n <mat-checkbox color="primary" formControlName="isExcluded" i18n
>Exclude from Analysis</mat-checkbox >Exclude from Analysis</mat-checkbox

6
apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts

@ -1,7 +1,11 @@
import { Account } from '@prisma/client'; import { User } from '@ghostfolio/common/interfaces';
import { Account, Tag } from '@prisma/client';
export interface CreateOrUpdateAccountDialogParams { export interface CreateOrUpdateAccountDialogParams {
account: Omit<Account, 'createdAt' | 'id' | 'updatedAt' | 'userId'> & { account: Omit<Account, 'createdAt' | 'id' | 'updatedAt' | 'userId'> & {
id: string | null; id: string | null;
tags?: Tag[];
}; };
user: User;
} }

11
libs/common/src/lib/dtos/create-account.dto.ts

@ -2,6 +2,8 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
ArrayUnique,
IsArray,
IsBoolean, IsBoolean,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -28,6 +30,9 @@ export class CreateAccountDto {
@IsString() @IsString()
id?: string; id?: string;
/**
* @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
*/
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isExcluded?: boolean; isExcluded?: boolean;
@ -38,4 +43,10 @@ export class CreateAccountDto {
@IsString() @IsString()
@ValidateIf((_object, value) => value !== null) @ValidateIf((_object, value) => value !== null)
platformId: string | null; platformId: string | null;
@ArrayUnique()
@IsArray()
@IsOptional()
@IsString({ each: true })
tags?: string[];
} }

11
libs/common/src/lib/dtos/update-account.dto.ts

@ -2,6 +2,8 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
ArrayUnique,
IsArray,
IsBoolean, IsBoolean,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -27,6 +29,9 @@ export class UpdateAccountDto {
@IsString() @IsString()
id: string; id: string;
/**
* @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
*/
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isExcluded?: boolean; isExcluded?: boolean;
@ -37,4 +42,10 @@ export class UpdateAccountDto {
@IsString() @IsString()
@ValidateIf((_object, value) => value !== null) @ValidateIf((_object, value) => value !== null)
platformId: string | null; platformId: string | null;
@ArrayUnique()
@IsArray()
@IsOptional()
@IsString({ each: true })
tags?: string[];
} }

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

@ -40,7 +40,8 @@ import {
DERIVED_CURRENCIES, DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks, ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioScraperApiSymbolPrefix ghostfolioScraperApiSymbolPrefix,
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from './config'; } from './config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -419,6 +420,18 @@ export function interpolate(template: string, context: any) {
}); });
} }
export function isAccountExcluded(account: {
isExcluded: boolean;
tags?: { id: string }[];
}) {
return (
account.isExcluded ||
account.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true
);
}
export function isCurrency(aCurrency: string) { export function isCurrency(aCurrency: string) {
if (!aCurrency) { if (!aCurrency) {
return false; return false;

1
libs/common/src/lib/interfaces/responses/export-response.interface.ts

@ -8,6 +8,7 @@ import { UserSettings } from '../user-settings.interface';
export interface ExportResponse { export interface ExportResponse {
accounts: (Omit<Account, 'createdAt' | 'updatedAt' | 'userId'> & { accounts: (Omit<Account, 'createdAt' | 'updatedAt' | 'userId'> & {
balances: AccountBalance[]; balances: AccountBalance[];
tags?: string[];
})[]; })[];
activities: (Omit< activities: (Omit<
Order, Order,

7
libs/common/src/lib/types/account-with-platform.type.ts

@ -1,3 +1,6 @@
import { Account, Platform } from '@prisma/client'; import { Account, Platform, Tag } from '@prisma/client';
export type AccountWithPlatform = Account & { platform?: Platform }; export type AccountWithPlatform = Account & {
platform?: Platform;
tags?: Tag[];
};

3
libs/common/src/lib/types/account-with-value.type.ts

@ -1,4 +1,4 @@
import { Account as AccountModel, Platform } from '@prisma/client'; import { Account as AccountModel, Platform, Tag } from '@prisma/client';
export type AccountWithValue = AccountModel & { export type AccountWithValue = AccountModel & {
activitiesCount: number; activitiesCount: number;
@ -7,6 +7,7 @@ export type AccountWithValue = AccountModel & {
dividendInBaseCurrency: number; dividendInBaseCurrency: number;
interestInBaseCurrency: number; interestInBaseCurrency: number;
platform?: Platform; platform?: Platform;
tags?: Tag[];
value: number; value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;
}; };

2
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -33,7 +33,7 @@
mat-cell mat-cell
> >
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
@if (element.isExcluded) { @if (isExcluded(element)) {
<ion-icon name="eye-off-outline" /> <ion-icon name="eye-off-outline" />
} }
</div> </div>

10
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -1,5 +1,9 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale, getLowercase } from '@ghostfolio/common/helper'; import {
getLocale,
getLowercase,
isAccountExcluded
} from '@ghostfolio/common/helper';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -137,6 +141,10 @@ export class GfAccountsTableComponent {
}); });
} }
protected isExcluded(account: Account & { tags?: { id: string }[] }) {
return isAccountExcluded(account);
}
protected onDeleteAccount(aId: string) { protected onDeleteAccount(aId: string) {
this.notificationService.confirm({ this.notificationService.confirm({
confirmFn: () => { confirmFn: () => {

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

@ -3,7 +3,7 @@ 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 } from '@ghostfolio/common/helper'; import { getLocale, isAccountExcluded } from '@ghostfolio/common/helper';
import { import {
Activity, Activity,
AssetProfileIdentifier AssetProfileIdentifier
@ -267,10 +267,10 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public isExcludedFromAnalysis(activity: Activity) { public isExcludedFromAnalysis(activity: Activity) {
return ( return (
activity.account?.isExcluded ?? (activity.account && isAccountExcluded(activity.account)) ||
activity.tags?.some(({ id }) => { activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) }) === true
); );
} }

18
libs/ui/src/lib/tags-selector/tags-selector.component.html

@ -2,11 +2,20 @@
<div class="col"> <div class="col">
@if (readonly) { @if (readonly) {
<div class="h5" i18n>Tags</div> <div class="h5" i18n>Tags</div>
@if (tags && tags.length > 0) { @if (
(tags && tags.length > 0) || (tagsReadOnly && tagsReadOnly.length > 0)
) {
<mat-chip-listbox> <mat-chip-listbox>
@if (tagsReadOnly) {
@for (tag of tagsReadOnly; track tag) {
<mat-chip-option disabled>{{ tag.name }}</mat-chip-option>
}
}
@if (tags) {
@for (tag of tags; track tag) { @for (tag of tags; track tag) {
<mat-chip-option disabled>{{ tag.name }}</mat-chip-option> <mat-chip-option disabled>{{ tag.name }}</mat-chip-option>
} }
}
</mat-chip-listbox> </mat-chip-listbox>
} @else { } @else {
<div>-</div> <div>-</div>
@ -15,6 +24,13 @@
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label> <mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList> <mat-chip-grid #tagsChipList>
@if (tagsReadOnly) {
@for (tag of tagsReadOnly; track tag.id) {
<mat-chip-row disabled [removable]="false">
{{ tag.name }}
</mat-chip-row>
}
}
@for (tag of tagsSelected(); track tag.id) { @for (tag of tagsSelected(); track tag.id) {
<mat-chip-row <mat-chip-row
matChipRemove matChipRemove

25
libs/ui/src/lib/tags-selector/tags-selector.component.ts

@ -9,6 +9,7 @@ import {
OnChanges, OnChanges,
OnInit, OnInit,
signal, signal,
SimpleChanges,
viewChild viewChild
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -27,7 +28,6 @@ import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Tag } from '@prisma/client';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { addCircleOutline, closeOutline } from 'ionicons/icons'; import { addCircleOutline, closeOutline } from 'ionicons/icons';
import { BehaviorSubject, Subject } from 'rxjs'; import { BehaviorSubject, Subject } from 'rxjs';
@ -65,6 +65,7 @@ export class GfTagsSelectorComponent
@Input() readonly = false; @Input() readonly = false;
@Input() tags: SelectedTag[]; @Input() tags: SelectedTag[];
@Input() tagsAvailable: SelectedTag[]; @Input() tagsAvailable: SelectedTag[];
@Input() tagsReadOnly: SelectedTag[];
public readonly filteredOptions: Subject<SelectedTag[]> = new BehaviorSubject( public readonly filteredOptions: Subject<SelectedTag[]> = new BehaviorSubject(
[] []
@ -87,12 +88,15 @@ export class GfTagsSelectorComponent
} }
public ngOnInit() { public ngOnInit() {
this.tagsSelected.set(this.tags); this.tagsSelected.set(this.tags ?? []);
this.updateFilters(); this.updateFilters();
} }
public ngOnChanges() { public ngOnChanges(changes: SimpleChanges) {
this.tagsSelected.set(this.tags); if (changes.tags) {
this.tagsSelected.set(this.tags ?? []);
}
this.updateFilters(); this.updateFilters();
} }
@ -123,7 +127,7 @@ export class GfTagsSelectorComponent
this.tagInputControl.setValue(null); this.tagInputControl.setValue(null);
} }
public onRemoveTag(tag: Tag) { public onRemoveTag(tag: SelectedTag) {
this.tagsSelected.update((tags) => { this.tagsSelected.update((tags) => {
return tags.filter(({ id }) => { return tags.filter(({ id }) => {
return id !== tag.id; return id !== tag.id;
@ -159,14 +163,19 @@ export class GfTagsSelectorComponent
private filterTags(query: string = ''): SelectedTag[] { private filterTags(query: string = ''): SelectedTag[] {
const tags = this.tagsSelected() ?? []; const tags = this.tagsSelected() ?? [];
const tagIds = tags.map(({ id }) => { const tagIds = [...tags, ...(this.tagsReadOnly ?? [])].map(({ id }) => {
return id; return id;
}); });
return this.tagsAvailable.filter(({ id, name }) => { return this.tagsAvailable
.filter(({ id, name }) => {
return ( return (
!tagIds.includes(id) && name.toLowerCase().includes(query.toLowerCase()) !tagIds.includes(id) &&
name.toLowerCase().includes(query.toLowerCase())
); );
})
.sort((a, b) => {
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
}); });
} }

20
prisma/migrations/20260705095904_added_tags_on_accounts/migration.sql

@ -0,0 +1,20 @@
-- CreateTable
CREATE TABLE "TagsOnAccounts" (
"accountId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tagId" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "TagsOnAccounts_pkey" PRIMARY KEY ("accountId","tagId","userId")
);
-- CreateIndex
CREATE INDEX "TagsOnAccounts_tagId_idx" ON "TagsOnAccounts"("tagId");
-- AddForeignKey
ALTER TABLE "TagsOnAccounts" ADD CONSTRAINT "TagsOnAccounts_accountId_userId_fkey" FOREIGN KEY ("accountId", "userId") REFERENCES "Account"("id", "userId") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TagsOnAccounts" ADD CONSTRAINT "TagsOnAccounts_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

16
prisma/schema.prisma

@ -33,10 +33,12 @@ model Account {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
currency String? currency String?
id String @default(uuid()) id String @default(uuid())
/// @deprecated Use the "Exclude from Analysis" tag (`TAG_ID_EXCLUDE_FROM_ANALYSIS`) instead
isExcluded Boolean @default(false) isExcluded Boolean @default(false)
name String? name String?
platform Platform? @relation(fields: [platformId], references: [id]) platform Platform? @relation(fields: [platformId], references: [id])
platformId String? platformId String?
tags TagsOnAccounts[]
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], onDelete: Cascade, references: [id]) user User @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String userId String
@ -251,6 +253,7 @@ model Subscription {
} }
model Tag { model Tag {
accounts TagsOnAccounts[]
activities Order[] activities Order[]
id String @id @default(uuid()) id String @id @default(uuid())
name String name String
@ -261,6 +264,19 @@ model Tag {
@@index([name]) @@index([name])
} }
model TagsOnAccounts {
account Account @relation(fields: [accountId, userId], onDelete: Cascade, references: [id, userId])
accountId String
createdAt DateTime @default(now())
tag Tag @relation(fields: [tagId], onDelete: Cascade, references: [id])
tagId String
updatedAt DateTime @updatedAt
userId String
@@id([accountId, tagId, userId])
@@index([tagId])
}
model User { model User {
accessesGet Access[] @relation("accessGet") accessesGet Access[] @relation("accessGet")
accessesGive Access[] @relation("accessGive") accessesGive Access[] @relation("accessGive")

Loading…
Cancel
Save