Browse Source

Add tags support in accounts

pull/7242/head
Thomas Kaul 1 week ago
parent
commit
0d3d5b7e22
  1. 3
      apps/api/src/app/account-balance/account-balance.service.ts
  2. 12
      apps/api/src/app/account/account.controller.ts
  3. 62
      apps/api/src/app/account/account.service.ts
  4. 72
      apps/api/src/app/activities/activities.service.ts
  5. 21
      apps/api/src/app/export/export.service.ts
  6. 129
      apps/api/src/app/import/import.service.ts
  7. 6
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 24
      apps/api/src/helper/account.helper.ts
  9. 7
      apps/api/src/services/tag/tag.service.ts
  10. 19
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  11. 84
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  12. 9
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  13. 6
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/interfaces/interfaces.ts
  14. 5
      libs/common/src/lib/dtos/create-account.dto.ts
  15. 5
      libs/common/src/lib/dtos/update-account.dto.ts
  16. 1
      libs/common/src/lib/interfaces/responses/export-response.interface.ts
  17. 7
      libs/common/src/lib/types/account-with-platform.type.ts
  18. 3
      libs/common/src/lib/types/account-with-value.type.ts
  19. 3
      libs/ui/src/lib/activities-table/activities-table.component.html
  20. 24
      libs/ui/src/lib/activities-table/activities-table.component.ts
  21. 20
      prisma/migrations/20260705095904_added_tags_on_accounts/migration.sql
  22. 19
      prisma/schema.prisma

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({

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

@ -164,6 +164,9 @@ export class AccountController {
{ {
...data, ...data,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
@ -174,6 +177,9 @@ export class AccountController {
return this.accountService.createAccount( return this.accountService.createAccount(
{ {
...data, ...data,
tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id
@ -262,6 +268,9 @@ export class AccountController {
data: { data: {
...data, ...data,
platform: { connect: { id: platformId } }, platform: { connect: { id: platformId } },
tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {
@ -284,6 +293,9 @@ export class AccountController {
platform: originalAccount.platformId platform: originalAccount.platformId
? { disconnect: true } ? { disconnect: true }
: undefined, : undefined,
tags: data.tags?.map((id) => {
return { id };
}),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}, },
where: { where: {

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

@ -1,5 +1,6 @@
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';
@ -13,7 +14,8 @@ 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';
@ -66,6 +68,7 @@ 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;
@ -77,6 +80,12 @@ export class AccountService {
...(isBalancesIncluded ? {} : { take: 1 }) ...(isBalancesIncluded ? {} : { take: 1 })
}; };
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 +96,42 @@ 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: (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag;
})
};
if (!isBalancesIncluded) { if (!isBalancesIncluded) {
delete account.balances; delete result.balances;
} }
return account; return result;
}); });
} }
public async createAccount( public async createAccount(
data: Prisma.AccountCreateInput, data: Prisma.AccountCreateInput & { tags?: { id: string }[] },
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
const tags = data.tags;
delete data.tags;
const account = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data data: {
...data,
tags: tags
? {
create: tags.map(({ id: tagId }) => {
return {
tag: { connect: { id: tagId } }
};
})
}
: undefined
}
}); });
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
@ -184,7 +213,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 }) => {
@ -220,12 +249,15 @@ export class AccountService {
public async updateAccount( public async updateAccount(
params: { params: {
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput; data: Prisma.AccountUpdateInput & { tags?: { id: string }[] };
}, },
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
const tags = data.tags;
delete data.tags;
await this.accountBalanceService.createOrUpdateAccountBalance({ await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: data.id as string, accountId: data.id as string,
balance: data.balance as number, balance: data.balance as number,
@ -234,7 +266,19 @@ export class AccountService {
}); });
const account = await this.prismaService.account.update({ const account = await this.prismaService.account.update({
data, data: {
...data,
tags: tags
? {
create: tags.map(({ id: tagId }) => {
return {
tag: { connect: { id: tagId } }
};
}),
deleteMany: {}
}
: undefined
},
where where
}); });

72
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 = { AND: andConditions, userId };
if (endDate || startDate) { if (endDate) {
where.AND = []; andConditions.push({ date: { lte: endDate } });
}
if (endDate) {
where.AND.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({
some: { OR: [
OR: filtersByTag.map(({ id }) => { {
return { id }; tags: {
}) some: {
} OR: filtersByTag.map(({ 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 {

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

@ -96,7 +96,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 +113,10 @@ export class ExportService {
id, id,
isExcluded, isExcluded,
name, name,
platformId platformId,
tags: tags.map(({ id: tagId }) => {
return tagId;
})
}; };
} }
); );
@ -151,11 +155,14 @@ export class ExportService {
.filter(({ id, isUsed }) => { .filter(({ id, isUsed }) => {
return ( return (
isUsed && isUsed &&
activities.some((activity) => { (accounts.some(({ tags: tagIds }) => {
return activity.tags.some(({ id: tagId }) => { return tagIds.includes(id);
return tagId === id; }) ||
}); activities.some((activity) => {
}) return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
}))
); );
}) })
.map(({ id, name }) => { .map(({ id, name }) => {

129
apps/api/src/app/import/import.service.ts

@ -194,19 +194,65 @@ 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;
if (!isDryRun && accountsWithBalancesDto?.length) { if (tagsDto?.length) {
const [existingAccounts, existingPlatforms] = await Promise.all([ const existingTagsOfUser = await this.tagService.getTagsForUser(user.id);
this.accountService.accounts({
where: { const canCreateOwnTag = hasPermission(
id: { user.permissions,
in: accountsWithBalancesDto.map(({ id }) => { permissions.createOwnTag
return id; );
})
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;
} }
} }
}), }
this.platformService.getPlatforms() }
]); }
if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms, existingTags] =
await Promise.all([
this.accountService.accounts({
where: {
id: {
in: accountsWithBalancesDto.map(({ id }) => {
return id;
})
}
}
}),
this.platformService.getPlatforms(),
this.tagService.getTagsForUser(user.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
@ -231,11 +277,26 @@ export class ImportService {
delete account.id; delete account.id;
} }
let accountObject: Prisma.AccountCreateInput = { const tagIds = (account.tags ?? [])
.map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
})
.filter((tagId) => {
return existingTags.some(({ id }) => {
return id === tagId;
});
});
let accountObject: Prisma.AccountCreateInput & {
tags?: { id: string }[];
} = {
...account, ...account,
balances: { balances: {
create: accountWithBalances.balances ?? [] create: accountWithBalances.balances ?? []
}, },
tags: tagIds.map((id) => {
return { id };
}),
user: { connect: { id: user.id } } user: { connect: { id: user.id } }
}; };
@ -320,50 +381,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)) {

6
apps/api/src/app/portfolio/portfolio.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 { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { UserService } from '@ghostfolio/api/app/user/user.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 { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
@ -1879,7 +1880,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,6 +2124,7 @@ 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) {
@ -2152,7 +2154,7 @@ export class PortfolioService {
} }
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

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

@ -0,0 +1,24 @@
import { TAG_ID_EXCLUDE_FROM_ANALYSIS } from '@ghostfolio/common/config';
import { Prisma, Tag } from '@prisma/client';
export const WHERE_ACCOUNT_NOT_EXCLUDED: Prisma.AccountWhereInput = {
isExcluded: false,
tags: {
none: {
tagId: TAG_ID_EXCLUDE_FROM_ANALYSIS
}
}
};
export function isAccountExcluded(account: {
isExcluded: boolean;
tags?: Tag[];
}) {
return (
account.isExcluded ||
account.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true
);
}

7
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,7 +85,7 @@ 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());

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'

84
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,9 +107,48 @@ 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 }) => {
this.platforms = platforms; this.platforms = platforms;
@ -132,7 +205,10 @@ 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?.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;
} }

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

@ -2,6 +2,7 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray,
IsBoolean, IsBoolean,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -38,4 +39,8 @@ export class CreateAccountDto {
@IsString() @IsString()
@ValidateIf((_object, value) => value !== null) @ValidateIf((_object, value) => value !== null)
platformId: string | null; platformId: string | null;
@IsArray()
@IsOptional()
tags?: string[];
} }

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

@ -2,6 +2,7 @@ import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray,
IsBoolean, IsBoolean,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -37,4 +38,8 @@ export class UpdateAccountDto {
@IsString() @IsString()
@ValidateIf((_object, value) => value !== null) @ValidateIf((_object, value) => value !== null)
platformId: string | null; platformId: string | null;
@IsArray()
@IsOptional()
tags?: string[];
} }

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;
}; };

3
libs/ui/src/lib/activities-table/activities-table.component.html

@ -166,6 +166,9 @@
@if (element.isDraft) { @if (element.isDraft) {
<span class="badge badge-secondary ml-1" i18n>Draft</span> <span class="badge badge-secondary ml-1" i18n>Draft</span>
} }
@for (tag of getTags(element); track tag.id) {
<span class="badge badge-light ml-1">{{ tag.name }}</span>
}
</div> </div>
@if ( @if (
element.SymbolProfile?.dataSource !== 'MANUAL' && element.SymbolProfile?.dataSource !== 'MANUAL' &&

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

@ -68,6 +68,7 @@ import {
tabletLandscapeOutline, tabletLandscapeOutline,
trashOutline trashOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import { uniqBy } from 'lodash';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfActivityTypeComponent } from '../activity-type/activity-type.component'; import { GfActivityTypeComponent } from '../activity-type/activity-type.component';
@ -265,12 +266,27 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
); );
} }
public getTags({ account, tags = [] }: Activity) {
return uniqBy([...tags, ...(account?.tags ?? [])], 'id')
.filter(({ id }) => {
return id !== TAG_ID_EXCLUDE_FROM_ANALYSIS;
})
.map((tag) => {
return {
...tag,
name: translate(tag.name)
};
});
}
public isExcludedFromAnalysis(activity: Activity) { public isExcludedFromAnalysis(activity: Activity) {
return ( return (
activity.account?.isExcluded ?? activity.account?.isExcluded ||
activity.tags?.some(({ id }) => { [...(activity.tags ?? []), ...(activity.account?.tags ?? [])].some(
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; ({ id }) => {
}) return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}
)
); );
} }

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;

19
prisma/schema.prisma

@ -37,6 +37,7 @@ model Account {
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,16 +252,30 @@ 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
user User? @relation(fields: [userId], onDelete: Cascade, references: [id]) user User? @relation(fields: [userId], onDelete: Cascade, references: [id])
userId String? userId String?
@@unique([name, userId]) @@unique([name, userId])
@@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