diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index 4851a1293..656fc2f63 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -1,4 +1,5 @@ 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -154,7 +155,7 @@ export class AccountBalanceService { } if (withExcludedAccounts === false) { - where.account = { isExcluded: false }; + where.account = WHERE_ACCOUNT_NOT_EXCLUDED; } const balances = await this.prismaService.accountBalance.findMany({ diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index d44b716c0..538aead13 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -164,6 +164,9 @@ export class AccountController { { ...data, platform: { connect: { id: platformId } }, + tags: data.tags?.map((id) => { + return { id }; + }), user: { connect: { id: this.request.user.id } } }, this.request.user.id @@ -174,6 +177,9 @@ export class AccountController { return this.accountService.createAccount( { ...data, + tags: data.tags?.map((id) => { + return { id }; + }), user: { connect: { id: this.request.user.id } } }, this.request.user.id @@ -262,6 +268,9 @@ export class AccountController { data: { ...data, platform: { connect: { id: platformId } }, + tags: data.tags?.map((id) => { + return { id }; + }), user: { connect: { id: this.request.user.id } } }, where: { @@ -284,6 +293,9 @@ export class AccountController { platform: originalAccount.platformId ? { disconnect: true } : undefined, + tags: data.tags?.map((id) => { + return { id }; + }), user: { connect: { id: this.request.user.id } } }, where: { diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index f030b73da..00bfd21d1 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,5 +1,6 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; 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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; @@ -13,7 +14,8 @@ import { Order, Platform, Prisma, - SymbolProfile + SymbolProfile, + Tag } from '@prisma/client'; import { Big } from 'big.js'; import { format } from 'date-fns'; @@ -66,6 +68,7 @@ export class AccountService { activities?: (Order & { SymbolProfile?: SymbolProfile })[]; balances?: AccountBalance[]; platform?: Platform; + tags?: Tag[]; })[] > { const { include = {}, skip, take, cursor, where, orderBy } = params; @@ -77,6 +80,12 @@ export class AccountService { ...(isBalancesIncluded ? {} : { take: 1 }) }; + include.tags = { + include: { + tag: true + } + }; + const accounts = await this.prismaService.account.findMany({ cursor, include, @@ -87,22 +96,42 @@ export class AccountService { }); 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) { - delete account.balances; + delete result.balances; } - return account; + return result; }); } public async createAccount( - data: Prisma.AccountCreateInput, + data: Prisma.AccountCreateInput & { tags?: { id: string }[] }, aUserId: string ): Promise { + const tags = data.tags; + delete data.tags; + 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({ @@ -184,7 +213,7 @@ export class AccountService { }; if (withExcludedAccounts === false) { - where.isExcluded = false; + where.AND = [WHERE_ACCOUNT_NOT_EXCLUDED]; } const { ACCOUNT: filtersByAccount = [] } = groupBy(filters, ({ type }) => { @@ -220,12 +249,15 @@ export class AccountService { public async updateAccount( params: { where: Prisma.AccountWhereUniqueInput; - data: Prisma.AccountUpdateInput; + data: Prisma.AccountUpdateInput & { tags?: { id: string }[] }; }, aUserId: string ): Promise { const { data, where } = params; + const tags = data.tags; + delete data.tags; + await this.accountBalanceService.createOrUpdateAccountBalance({ accountId: data.id as string, balance: data.balance as number, @@ -234,7 +266,19 @@ export class AccountService { }); 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 }); diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 816d2328d..a2181d853 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/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 { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-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 { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; @@ -568,18 +569,15 @@ export class ActivitiesService { { date: 'asc' } ]; - const where: Prisma.OrderWhereInput = { userId }; + const andConditions: Prisma.OrderWhereInput[] = []; + const where: Prisma.OrderWhereInput = { AND: andConditions, userId }; - if (endDate || startDate) { - where.AND = []; - - if (endDate) { - where.AND.push({ date: { lte: endDate } }); - } + if (endDate) { + andConditions.push({ date: { lte: endDate } }); + } - if (startDate) { - where.AND.push({ date: { gt: startDate } }); - } + if (startDate) { + andConditions.push({ date: { gt: startDate } }); } const { @@ -682,13 +680,30 @@ export class ActivitiesService { } if (filtersByTag.length > 0) { - where.tags = { - some: { - OR: filtersByTag.map(({ id }) => { - return { id }; - }) - } - }; + andConditions.push({ + OR: [ + { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { id }; + }) + } + } + }, + { + account: { + tags: { + some: { + OR: filtersByTag.map(({ id }) => { + return { tagId: id }; + }) + } + } + } + } + ] + }); } if (sortColumn) { @@ -700,13 +715,9 @@ export class ActivitiesService { } if (withExcludedAccountsAndActivities === false) { - where.OR = [ - { account: null }, - { account: { NOT: { isExcluded: true } } } - ]; + where.OR = [{ account: null }, { account: WHERE_ACCOUNT_NOT_EXCLUDED }]; where.tags = { - ...where.tags, none: { id: TAG_ID_EXCLUDE_FROM_ANALYSIS } @@ -721,7 +732,12 @@ export class ActivitiesService { include: { account: { include: { - platform: true + platform: true, + tags: { + include: { + tag: true + } + } } }, // eslint-disable-next-line @typescript-eslint/naming-convention @@ -733,6 +749,16 @@ export class ActivitiesService { 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( orders.map(({ SymbolProfile }) => { return { diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts index b94b2aa74..371a3f03d 100644 --- a/apps/api/src/app/export/export.service.ts +++ b/apps/api/src/app/export/export.service.ts @@ -96,7 +96,8 @@ export class ExportService { isExcluded, name, platform, - platformId + platformId, + tags }) => { if (platformId) { platformsMap[platformId] = platform; @@ -112,7 +113,10 @@ export class ExportService { id, isExcluded, name, - platformId + platformId, + tags: tags.map(({ id: tagId }) => { + return tagId; + }) }; } ); @@ -151,11 +155,14 @@ export class ExportService { .filter(({ id, isUsed }) => { return ( isUsed && - activities.some((activity) => { - return activity.tags.some(({ id: tagId }) => { - return tagId === id; - }); - }) + (accounts.some(({ tags: tagIds }) => { + return tagIds.includes(id); + }) || + activities.some((activity) => { + return activity.tags.some(({ id: tagId }) => { + return tagId === id; + }); + })) ); }) .map(({ id, name }) => { diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index c7dab9823..840e08586 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -194,19 +194,65 @@ export class ImportService { const tagIdMapping: { [oldTagId: string]: string } = {}; const userCurrency = user.settings.settings.baseCurrency; - if (!isDryRun && accountsWithBalancesDto?.length) { - const [existingAccounts, existingPlatforms] = await Promise.all([ - this.accountService.accounts({ - where: { - id: { - in: accountsWithBalancesDto.map(({ id }) => { - return id; - }) + 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; } } - }), - 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) { // Check if there is any existing account with the same ID @@ -231,11 +277,26 @@ export class ImportService { 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, balances: { create: accountWithBalances.balances ?? [] }, + tags: tagIds.map((id) => { + return { 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) { if (!activity.dataSource) { if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index b5ea9ba00..e505f110a 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/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 { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { isAccountExcluded } from '@ghostfolio/api/helper/account.helper'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; @@ -1879,7 +1880,7 @@ export class PortfolioService { for (const activity of activities) { if ( - activity.account?.isExcluded || + (activity.account && isAccountExcluded(activity.account)) || activity.tags?.some(({ id }) => { return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; }) @@ -2123,6 +2124,7 @@ export class PortfolioService { let currentAccounts: (Account & { Order?: Order[]; platform?: Platform; + tags?: Tag[]; })[] = []; if (filters.length === 0) { @@ -2152,7 +2154,7 @@ export class PortfolioService { } 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 diff --git a/apps/api/src/helper/account.helper.ts b/apps/api/src/helper/account.helper.ts new file mode 100644 index 000000000..206ae198a --- /dev/null +++ b/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 + ); +} diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts index f4cbd4cb1..1c7b5c687 100644 --- a/apps/api/src/services/tag/tag.service.ts +++ b/apps/api/src/services/tag/tag.service.ts @@ -52,6 +52,11 @@ export class TagService { include: { _count: { select: { + accounts: { + where: { + userId + } + }, activities: { where: { userId @@ -80,7 +85,7 @@ export class TagService { id, name, userId, - isUsed: _count.activities > 0 + isUsed: _count.accounts > 0 || _count.activities > 0 })) .sort((a, b) => { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts index c99fc40ac..907b39d37 100644 --- a/apps/client/src/app/pages/accounts/accounts-page.component.ts +++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts @@ -25,7 +25,7 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatDialog } from '@angular/material/dialog'; 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 { EMPTY } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -188,8 +188,9 @@ export class GfAccountsPageComponent implements OnInit { id, isExcluded, name, - platformId - }: AccountModel) { + platformId, + tags + }: AccountModel & { tags?: Tag[] }) { const dialogRef = this.dialog.open< GfCreateOrUpdateAccountDialogComponent, CreateOrUpdateAccountDialogParams @@ -202,8 +203,10 @@ export class GfAccountsPageComponent implements OnInit { id, isExcluded, name, - platformId - } + platformId, + tags + }, + user: this.user }, height: this.deviceType() === 'mobile' ? '98vh' : '80vh', width: this.deviceType() === 'mobile' ? '100vw' : '50rem' @@ -277,8 +280,10 @@ export class GfAccountsPageComponent implements OnInit { id: null, isExcluded: false, name: null, - platformId: null - } + platformId: null, + tags: [] + }, + user: this.user } satisfies CreateOrUpdateAccountDialogParams, height: this.deviceType() === 'mobile' ? '98vh' : '80vh', width: this.deviceType() === 'mobile' ? '100vw' : '50rem' diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts index 8319999eb..5964b0c69 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts @@ -1,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 { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { validateObjectForForm } from '@ghostfolio/common/utils'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; +import { translate } from '@ghostfolio/ui/i18n'; import { DataService } from '@ghostfolio/ui/services'; +import { GfTagsSelectorComponent } from '@ghostfolio/ui/tags-selector'; 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 { AbstractControl, FormBuilder, @@ -24,7 +35,7 @@ import { } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { Platform } from '@prisma/client'; +import { Platform, Tag } from '@prisma/client'; import { Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; @@ -37,6 +48,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces'; CommonModule, GfCurrencySelectorComponent, GfEntityLogoComponent, + GfTagsSelectorComponent, MatAutocompleteModule, MatButtonModule, MatCheckboxModule, @@ -53,19 +65,41 @@ export class GfCreateOrUpdateAccountDialogComponent { protected accountForm: FormGroup; protected currencies: string[] = []; protected filteredPlatforms: Observable | undefined; + protected hasPermissionToCreateOwnTag: boolean | undefined; protected platforms: Platform[] = []; + protected tagsAvailable: Tag[] = []; protected readonly data = inject(MAT_DIALOG_DATA); private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); private readonly dialogRef = inject>(MatDialogRef); private readonly formBuilder = inject(FormBuilder); + private readonly userService = inject(UserService); public ngOnInit() { const { currencies } = this.dataService.fetchInfo(); 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({ accountId: [{ disabled: true, value: this.data.account.id }], balance: [this.data.account.balance, Validators.required], @@ -73,9 +107,48 @@ export class GfCreateOrUpdateAccountDialogComponent { currency: [this.data.account.currency, Validators.required], isExcluded: [this.data.account.isExcluded], 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.platforms = platforms; @@ -132,7 +205,10 @@ export class GfCreateOrUpdateAccountDialogComponent { id: this.accountForm.get('accountId')?.value, isExcluded: this.accountForm.get('isExcluded')?.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 { diff --git a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html index c90c9c440..c707a9402 100644 --- a/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html +++ b/apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html @@ -85,6 +85,15 @@ > + @if (data.user?.settings?.isExperimentalFeatures) { +
+ +
+ }
Exclude from Analysis & { id: string | null; + tags?: Tag[]; }; + user: User; } diff --git a/libs/common/src/lib/dtos/create-account.dto.ts b/libs/common/src/lib/dtos/create-account.dto.ts index fa88580f1..883df6bc5 100644 --- a/libs/common/src/lib/dtos/create-account.dto.ts +++ b/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 { + IsArray, IsBoolean, IsNumber, IsOptional, @@ -38,4 +39,8 @@ export class CreateAccountDto { @IsString() @ValidateIf((_object, value) => value !== null) platformId: string | null; + + @IsArray() + @IsOptional() + tags?: string[]; } diff --git a/libs/common/src/lib/dtos/update-account.dto.ts b/libs/common/src/lib/dtos/update-account.dto.ts index 066bacbfd..9f6b50633 100644 --- a/libs/common/src/lib/dtos/update-account.dto.ts +++ b/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 { + IsArray, IsBoolean, IsNumber, IsOptional, @@ -37,4 +38,8 @@ export class UpdateAccountDto { @IsString() @ValidateIf((_object, value) => value !== null) platformId: string | null; + + @IsArray() + @IsOptional() + tags?: string[]; } diff --git a/libs/common/src/lib/interfaces/responses/export-response.interface.ts b/libs/common/src/lib/interfaces/responses/export-response.interface.ts index beffad7f1..89c54d884 100644 --- a/libs/common/src/lib/interfaces/responses/export-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/export-response.interface.ts @@ -8,6 +8,7 @@ import { UserSettings } from '../user-settings.interface'; export interface ExportResponse { accounts: (Omit & { balances: AccountBalance[]; + tags?: string[]; })[]; activities: (Omit< Order, diff --git a/libs/common/src/lib/types/account-with-platform.type.ts b/libs/common/src/lib/types/account-with-platform.type.ts index fbaa47393..640245e3b 100644 --- a/libs/common/src/lib/types/account-with-platform.type.ts +++ b/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[]; +}; diff --git a/libs/common/src/lib/types/account-with-value.type.ts b/libs/common/src/lib/types/account-with-value.type.ts index 23cb14749..a13530632 100644 --- a/libs/common/src/lib/types/account-with-value.type.ts +++ b/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 & { activitiesCount: number; @@ -7,6 +7,7 @@ export type AccountWithValue = AccountModel & { dividendInBaseCurrency: number; interestInBaseCurrency: number; platform?: Platform; + tags?: Tag[]; value: number; valueInBaseCurrency: number; }; diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html index a98d51f56..912a2742f 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.html +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -166,6 +166,9 @@ @if (element.isDraft) { Draft } + @for (tag of getTags(element); track tag.id) { + {{ tag.name }} + }
@if ( element.SymbolProfile?.dataSource !== 'MANUAL' && diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts index 766abcc23..59e4b6d0c 100644 --- a/libs/ui/src/lib/activities-table/activities-table.component.ts +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -68,6 +68,7 @@ import { tabletLandscapeOutline, trashOutline } from 'ionicons/icons'; +import { uniqBy } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; 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) { return ( - activity.account?.isExcluded ?? - activity.tags?.some(({ id }) => { - return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; - }) + activity.account?.isExcluded || + [...(activity.tags ?? []), ...(activity.account?.tags ?? [])].some( + ({ id }) => { + return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; + } + ) ); } diff --git a/prisma/migrations/20260705095904_added_tags_on_accounts/migration.sql b/prisma/migrations/20260705095904_added_tags_on_accounts/migration.sql new file mode 100644 index 000000000..c2fdf4e5d --- /dev/null +++ b/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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2c39667ee..cd32b4a10 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -37,6 +37,7 @@ model Account { name String? platform Platform? @relation(fields: [platformId], references: [id]) platformId String? + tags TagsOnAccounts[] updatedAt DateTime @updatedAt user User @relation(fields: [userId], onDelete: Cascade, references: [id]) userId String @@ -251,16 +252,30 @@ model Subscription { } model Tag { + accounts TagsOnAccounts[] activities Order[] - id String @id @default(uuid()) + id String @id @default(uuid()) name String - user User? @relation(fields: [userId], onDelete: Cascade, references: [id]) + user User? @relation(fields: [userId], onDelete: Cascade, references: [id]) userId String? @@unique([name, userId]) @@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 { accessesGet Access[] @relation("accessGet") accessesGive Access[] @relation("accessGive")