Browse Source

Add tags support in accounts

pull/7242/head
Thomas Kaul 1 week ago
parent
commit
5ae3327ba3
  1. 52
      apps/api/src/app/account/account.controller.ts
  2. 91
      apps/api/src/app/account/account.service.ts
  3. 2
      apps/api/src/app/activities/activities.service.ts
  4. 2
      apps/api/src/app/endpoints/tags/tags.controller.ts
  5. 3
      apps/api/src/app/export/export.service.ts
  6. 73
      apps/api/src/app/import/import.service.ts
  7. 9
      apps/api/src/app/portfolio/portfolio.service.ts
  8. 14
      apps/api/src/helper/account.helper.ts
  9. 9
      apps/api/src/services/tag/tag.service.ts
  10. 20
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  11. 1
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  12. 12
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  13. 3
      libs/common/src/lib/dtos/create-account.dto.ts
  14. 3
      libs/common/src/lib/dtos/update-account.dto.ts
  15. 15
      libs/common/src/lib/helper.ts
  16. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  17. 10
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  18. 12
      libs/ui/src/lib/activities-table/activities-table.component.ts

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

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

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

@ -6,7 +6,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { HttpException, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
Account,
@ -19,6 +19,7 @@ import {
} from '@prisma/client';
import { Big } from 'big.js';
import { format } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -74,17 +75,20 @@ export class AccountService {
const { include = {}, skip, take, cursor, where, orderBy } = params;
const isBalancesIncluded = !!include.balances;
const isTagsIncluded = !!include.tags;
include.balances = {
orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 })
};
include.tags = {
include: {
tag: true
}
};
if (isTagsIncluded) {
include.tags = {
include: {
tag: true
}
};
}
const accounts = await this.prismaService.account.findMany({
cursor,
@ -99,32 +103,38 @@ export class AccountService {
const result = {
...account,
balance: account.balances[0]?.value ?? 0,
tags: (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag;
})
tags: isTagsIncluded
? (account.tags as unknown as { tag: Tag }[]).map(({ tag }) => {
return tag;
})
: undefined
};
if (!isBalancesIncluded) {
delete result.balances;
}
if (!isTagsIncluded) {
delete result.tags;
}
return result;
});
}
public async createAccount(
data: Prisma.AccountCreateInput & { tags?: { id: string }[] },
aUserId: string
data: Prisma.AccountCreateInput,
aUserId: string,
tagIds?: string[]
): Promise<Account> {
const tags = data.tags;
delete data.tags;
await this.validateTagIds(tagIds, aUserId);
const account = await this.prismaService.account.create({
data: {
...data,
tags: tags
tags: tagIds
? {
create: tags.map(({ id: tagId }) => {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
@ -172,7 +182,8 @@ export class AccountService {
const accounts = await this.accounts({
include: {
activities: true,
platform: true
platform: true,
tags: true
},
orderBy: { name: 'asc' },
where: { userId: aUserId }
@ -249,28 +260,21 @@ export class AccountService {
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput & { tags?: { id: string }[] };
data: Prisma.AccountUpdateInput;
},
aUserId: string
aUserId: string,
tagIds?: string[]
): Promise<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,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
await this.validateTagIds(tagIds, aUserId);
const account = await this.prismaService.account.update({
data: {
...data,
tags: tags
tags: tagIds
? {
create: tags.map(({ id: tagId }) => {
create: tagIds.map((tagId) => {
return {
tag: { connect: { id: tagId } }
};
@ -282,6 +286,13 @@ export class AccountService {
where
});
await this.accountBalanceService.createOrUpdateAccountBalance({
accountId: account.id,
balance: data.balance as number,
date: format(new Date(), DATE_FORMAT),
userId: aUserId
});
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
@ -329,4 +340,26 @@ export class AccountService {
});
}
}
private async validateTagIds(tagIds: string[], userId: string) {
if (!tagIds?.length) {
return;
}
const uniqueTagIds = Array.from(new Set(tagIds));
const tagsCount = await this.prismaService.tag.count({
where: {
id: { in: uniqueTagIds },
OR: [{ userId }, { userId: null }]
}
});
if (tagsCount !== uniqueTagIds.length) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

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

@ -570,7 +570,7 @@ export class ActivitiesService {
];
const andConditions: Prisma.OrderWhereInput[] = [];
const where: Prisma.OrderWhereInput = { AND: andConditions, userId };
const where: Prisma.OrderWhereInput = { userId, AND: andConditions };
if (endDate) {
andConditions.push({ date: { lte: endDate } });

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

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

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

@ -72,7 +72,8 @@ export class ExportService {
where,
include: {
balances: true,
platform: true
platform: true,
tags: true
},
orderBy: {
name: 'asc'

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

@ -10,11 +10,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathe
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
CreateAssetProfileDto,
CreateAccountDto,
CreateOrderDto
} from '@ghostfolio/common/dtos';
import { CreateAssetProfileDto, CreateOrderDto } from '@ghostfolio/common/dtos';
import {
getAssetProfileIdentifier,
parseDate
@ -194,9 +190,12 @@ export class ImportService {
const tagIdMapping: { [oldTagId: string]: string } = {};
const userCurrency = user.settings.settings.baseCurrency;
if (tagsDto?.length) {
const existingTagsOfUser = await this.tagService.getTagsForUser(user.id);
const existingTagsOfUser =
tagsDto?.length || (!isDryRun && accountsWithBalancesDto?.length)
? await this.tagService.getTagsForUser(user.id)
: [];
if (tagsDto?.length) {
const canCreateOwnTag = hasPermission(
user.permissions,
permissions.createOwnTag
@ -207,7 +206,7 @@ export class ImportService {
return id === tag.id;
});
if (!existingTagOfUser || existingTagOfUser.userId !== null) {
if (!existingTagOfUser) {
if (!canCreateOwnTag) {
throw new Error(
`Insufficient permissions to create custom tag ("${tag.name}")`
@ -233,26 +232,37 @@ export class ImportService {
if (existingTag && oldTagId) {
tagIdMapping[oldTagId] = newTag.id;
}
existingTagsOfUser.push({
id: newTag.id,
isUsed: false,
name: newTag.name,
userId: newTag.userId
});
}
}
}
}
if (!isDryRun && accountsWithBalancesDto?.length) {
const [existingAccounts, existingPlatforms, existingTags] =
await Promise.all([
this.accountService.accounts({
where: {
id: {
in: accountsWithBalancesDto.map(({ id }) => {
return id;
})
}
const [existingAccounts, existingPlatforms] = await Promise.all([
this.accountService.accounts({
where: {
id: {
in: accountsWithBalancesDto.map(({ id }) => {
return id;
})
}
}),
this.platformService.getPlatforms(),
this.tagService.getTagsForUser(user.id)
]);
}
}),
this.platformService.getPlatforms()
]);
const existingTagIds = new Set(
existingTagsOfUser.map(({ id }) => {
return id;
})
);
for (const accountWithBalances of accountsWithBalancesDto) {
// Check if there is any existing account with the same ID
@ -262,10 +272,7 @@ export class ImportService {
// If there is no account or if the account belongs to a different user then create a new account
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
const account: CreateAccountDto = omit(
accountWithBalances,
'balances'
);
const account = omit(accountWithBalances, ['balances', 'tags']);
let oldAccountId: string;
const platformId = account.platformId;
@ -277,26 +284,19 @@ export class ImportService {
delete account.id;
}
const tagIds = (account.tags ?? [])
const tagIds = (accountWithBalances.tags ?? [])
.map((tagId) => {
return tagIdMapping[tagId] ?? tagId;
})
.filter((tagId) => {
return existingTags.some(({ id }) => {
return id === tagId;
});
return existingTagIds.has(tagId);
});
let accountObject: Prisma.AccountCreateInput & {
tags?: { id: string }[];
} = {
let accountObject: Prisma.AccountCreateInput = {
...account,
balances: {
create: accountWithBalances.balances ?? []
},
tags: tagIds.map((id) => {
return { id };
}),
user: { connect: { id: user.id } }
};
@ -313,7 +313,8 @@ export class ImportService {
const newAccount = await this.accountService.createAccount(
accountObject,
user.id
user.id,
tagIds
);
// Store the new to old account ID mappings for updating activities

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

@ -3,7 +3,6 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { isAccountExcluded } from '@ghostfolio/api/helper/account.helper';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
@ -42,6 +41,7 @@ import {
DATE_FORMAT,
getAssetProfileIdentifier,
getSum,
isAccountExcluded,
parseDate
} from '@ghostfolio/common/helper';
import {
@ -170,7 +170,8 @@ export class PortfolioService {
where,
include: {
activities: { include: { SymbolProfile: true } },
platform: true
platform: true,
tags: true
},
orderBy: { name: 'asc' }
}),
@ -2131,7 +2132,7 @@ export class PortfolioService {
currentAccounts = await this.accountService.getAccounts(userId);
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({
include: { platform: true },
include: { platform: true, tags: true },
where: { id: filters[0].id }
});
} else {
@ -2148,7 +2149,7 @@ export class PortfolioService {
);
currentAccounts = await this.accountService.accounts({
include: { platform: true },
include: { platform: true, tags: true },
where: { id: { in: accountIds } }
});
}

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

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

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

@ -92,20 +92,21 @@ export class TagService {
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
public async getTagsWithAccountAndActivityCount() {
const tagsWithAccountAndOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { activities: true }
select: { accounts: true, activities: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name, userId }) => {
return tagsWithAccountAndOrderCount.map(({ _count, id, name, userId }) => {
return {
id,
name,
userId,
accountCount: _count.accounts,
activityCount: _count.activities
};
});

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

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

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

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

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

@ -206,9 +206,15 @@ export class GfCreateOrUpdateAccountDialogComponent {
isExcluded: this.accountForm.get('isExcluded')?.value,
name: this.accountForm.get('name')?.value,
platformId: this.accountForm.get('platformId')?.value?.id || null,
tags: this.accountForm.get('tags')?.value?.map(({ id }: Tag) => {
return id;
})
tags: this.accountForm
.get('tags')
?.value?.filter(({ id }: Tag) => {
// Skip tags which have not been created yet
return !!id;
})
.map(({ id }: Tag) => {
return id;
})
};
try {

3
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 {
ArrayUnique,
IsArray,
IsBoolean,
IsNumber,
@ -40,7 +41,9 @@ export class CreateAccountDto {
@ValidateIf((_object, value) => value !== null)
platformId: string | null;
@ArrayUnique()
@IsArray()
@IsOptional()
@IsString({ each: true })
tags?: string[];
}

3
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 {
ArrayUnique,
IsArray,
IsBoolean,
IsNumber,
@ -39,7 +40,9 @@ export class UpdateAccountDto {
@ValidateIf((_object, value) => value !== null)
platformId: string | null;
@ArrayUnique()
@IsArray()
@IsOptional()
@IsString({ each: true })
tags?: string[];
}

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

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

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

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

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

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

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

@ -3,7 +3,7 @@ import {
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper';
import { getLocale, isAccountExcluded } from '@ghostfolio/common/helper';
import {
Activity,
AssetProfileIdentifier
@ -281,12 +281,10 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
public isExcludedFromAnalysis(activity: Activity) {
return (
activity.account?.isExcluded ||
[...(activity.tags ?? []), ...(activity.account?.tags ?? [])].some(
({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}
)
(activity.account && isAccountExcluded(activity.account)) ||
activity.tags?.some(({ id }) => {
return id === TAG_ID_EXCLUDE_FROM_ANALYSIS;
}) === true
);
}

Loading…
Cancel
Save