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

12
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: {

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 { 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<Account> {
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<Account> {
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
});

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

21
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 }) => {

129
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)) {

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

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: {
_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());

19
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'

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 { 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<Platform[]> | undefined;
protected hasPermissionToCreateOwnTag: boolean | undefined;
protected platforms: Platform[] = [];
protected tagsAvailable: Tag[] = [];
protected readonly data =
inject<CreateOrUpdateAccountDialogParams>(MAT_DIALOG_DATA);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly dialogRef =
inject<MatDialogRef<GfCreateOrUpdateAccountDialogComponent>>(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 {

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

@ -85,6 +85,15 @@
></textarea>
</mat-form-field>
</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">
<mat-checkbox color="primary" formControlName="isExcluded" i18n
>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 {
account: Omit<Account, 'createdAt' | 'id' | 'updatedAt' | 'userId'> & {
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 {
IsArray,
IsBoolean,
IsNumber,
IsOptional,
@ -38,4 +39,8 @@ export class CreateAccountDto {
@IsString()
@ValidateIf((_object, value) => value !== 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 {
IsArray,
IsBoolean,
IsNumber,
IsOptional,
@ -37,4 +38,8 @@ export class UpdateAccountDto {
@IsString()
@ValidateIf((_object, value) => value !== 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 {
accounts: (Omit<Account, 'createdAt' | 'updatedAt' | 'userId'> & {
balances: AccountBalance[];
tags?: string[];
})[];
activities: (Omit<
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 & {
activitiesCount: number;
@ -7,6 +7,7 @@ export type AccountWithValue = AccountModel & {
dividendInBaseCurrency: number;
interestInBaseCurrency: number;
platform?: Platform;
tags?: Tag[];
value: number;
valueInBaseCurrency: number;
};

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

@ -166,6 +166,9 @@
@if (element.isDraft) {
<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>
@if (
element.SymbolProfile?.dataSource !== 'MANUAL' &&

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

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?
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")

Loading…
Cancel
Save