From bad9d17c44217ae5631cd9a4ffdb7ccb3161cc7a Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 24 Apr 2022 16:23:03 +0200 Subject: [PATCH] Setup allocations page and endpoint (#859) * Setup tagging system * Update changelog --- CHANGELOG.md | 4 ++ apps/api/src/app/info/info.module.ts | 4 +- apps/api/src/app/info/info.service.ts | 7 ++- apps/api/src/app/order/order.service.ts | 14 ++++++ .../src/app/portfolio/portfolio.controller.ts | 12 +++-- .../src/app/portfolio/portfolio.service.ts | 13 ++++-- apps/api/src/app/user/user.controller.ts | 2 +- apps/api/src/app/user/user.module.ts | 4 +- apps/api/src/app/user/user.service.ts | 14 +++++- apps/api/src/services/tag/tag.module.ts | 11 +++++ apps/api/src/services/tag/tag.service.ts | 30 +++++++++++++ .../allocations/allocations-page.component.ts | 44 +++++++++++++------ .../allocations/allocations-page.html | 6 +++ .../allocations/allocations-page.module.ts | 2 + apps/client/src/app/services/data.service.ts | 10 ++++- .../src/lib/interfaces/info-item.interface.ts | 2 + .../src/lib/interfaces/user.interface.ts | 3 +- prisma/schema.prisma | 7 +++ 18 files changed, 160 insertions(+), 29 deletions(-) create mode 100644 apps/api/src/services/tag/tag.module.ts create mode 100644 apps/api/src/services/tag/tag.service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff5c940b..d795f905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a tagging system for activities + ### Changed - Extracted the activities table filter to a dedicated component diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/info/info.module.ts index 5828ac96..338747eb 100644 --- a/apps/api/src/app/info/info.module.ts +++ b/apps/api/src/app/info/info.module.ts @@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; @@ -26,7 +27,8 @@ import { InfoService } from './info.service'; PrismaModule, PropertyModule, RedisCacheModule, - SymbolProfileModule + SymbolProfileModule, + TagModule ], providers: [InfoService] }) diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 60df05c5..032b05f2 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { DEMO_USER_ID, PROPERTY_IS_READ_ONLY_MODE, @@ -33,7 +34,8 @@ export class InfoService { private readonly jwtService: JwtService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, - private readonly redisCacheService: RedisCacheService + private readonly redisCacheService: RedisCacheService, + private readonly tagService: TagService ) {} public async get(): Promise { @@ -105,7 +107,8 @@ export class InfoService { demoAuthToken: this.getDemoAuthToken(), lastDataGathering: await this.getLastDataGathering(), statistics: await this.getStatistics(), - subscriptions: await this.getSubscriptions() + subscriptions: await this.getSubscriptions(), + tags: await this.tagService.get() }; } diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index ee01b309..131b09b1 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -152,11 +152,13 @@ export class OrderService { public async getOrders({ includeDrafts = false, + tags, types, userCurrency, userId }: { includeDrafts?: boolean; + tags?: string[]; types?: TypeOfOrder[]; userCurrency: string; userId: string; @@ -167,6 +169,18 @@ export class OrderService { where.isDraft = false; } + if (tags?.length > 0) { + where.tags = { + some: { + OR: tags.map((tag) => { + return { + name: tag + }; + }) + } + }; + } + if (types) { where.OR = types.map((type) => { return { diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index ad06dbb5..9f9b20c5 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -105,7 +105,8 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( @Headers('impersonation-id') impersonationId: string, - @Query('range') range + @Query('range') range, + @Query('tags') tags?: string ): Promise { let hasError = false; @@ -113,7 +114,8 @@ export class PortfolioController { await this.portfolioService.getDetails( impersonationId, this.request.user.id, - range + range, + tags?.split(',') ); if (hasErrors || hasNotDefinedValuesInObject(holdings)) { @@ -159,7 +161,11 @@ export class PortfolioController { this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.request.user.subscription.type === 'Basic'; - return { accounts, hasError, holdings: isBasicUser ? {} : holdings }; + return { + hasError, + accounts: tags ? {} : accounts, + holdings: isBasicUser ? {} : holdings + }; } @Get('investments') diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index a7dc8adf..86fc1398 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -303,7 +303,8 @@ export class PortfolioService { public async getDetails( aImpersonationId: string, aUserId: string, - aDateRange: DateRange = 'max' + aDateRange: DateRange = 'max', + tags?: string[] ): Promise { const userId = await this.getUserId(aImpersonationId, aUserId); const user = await this.userService.user({ id: userId }); @@ -318,6 +319,7 @@ export class PortfolioService { const { orders, portfolioOrders, transactionPoints } = await this.getTransactionPoints({ + tags, userId }); @@ -441,8 +443,10 @@ export class PortfolioService { value: totalValue }); - for (const symbol of Object.keys(cashPositions)) { - holdings[symbol] = cashPositions[symbol]; + if (tags === undefined) { + for (const symbol of Object.keys(cashPositions)) { + holdings[symbol] = cashPositions[symbol]; + } } const accounts = await this.getValueOfAccounts( @@ -1178,9 +1182,11 @@ export class PortfolioService { private async getTransactionPoints({ includeDrafts = false, + tags, userId }: { includeDrafts?: boolean; + tags?: string[]; userId: string; }): Promise<{ transactionPoints: TransactionPoint[]; @@ -1191,6 +1197,7 @@ export class PortfolioService { const orders = await this.orderService.getOrders({ includeDrafts, + tags, userCurrency, userId, types: ['BUY', 'SELL'] diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 5bd14cfa..e79f61dd 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -34,7 +34,7 @@ import { UserService } from './user.service'; export class UserController { public constructor( private readonly configurationService: ConfigurationService, - private jwtService: JwtService, + private readonly jwtService: JwtService, private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly userService: UserService diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/user/user.module.ts index 976f5b6c..6a705524 100644 --- a/apps/api/src/app/user/user.module.ts +++ b/apps/api/src/app/user/user.module.ts @@ -2,6 +2,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; @@ -19,7 +20,8 @@ import { UserService } from './user.service'; }), PrismaModule, PropertyModule, - SubscriptionModule + SubscriptionModule, + TagModule ], providers: [UserService] }) diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index feed4643..0995ace2 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -2,6 +2,7 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { PROPERTY_IS_READ_ONLY_MODE, baseCurrency, @@ -13,7 +14,6 @@ import { hasRole, permissions } from '@ghostfolio/common/permissions'; -import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { Injectable } from '@nestjs/common'; import { Prisma, Role, User, ViewMode } from '@prisma/client'; @@ -30,7 +30,8 @@ export class UserService { private readonly configurationService: ConfigurationService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, - private readonly subscriptionService: SubscriptionService + private readonly subscriptionService: SubscriptionService, + private readonly tagService: TagService ) {} public async getUser( @@ -51,12 +52,21 @@ export class UserService { orderBy: { User: { alias: 'asc' } }, where: { GranteeUser: { id } } }); + let tags = await this.tagService.getByUser(id); + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + subscription.type === 'Basic' + ) { + tags = []; + } return { alias, id, permissions, subscription, + tags, access: access.map((accessItem) => { return { alias: accessItem.User.alias, diff --git a/apps/api/src/services/tag/tag.module.ts b/apps/api/src/services/tag/tag.module.ts new file mode 100644 index 00000000..32d90588 --- /dev/null +++ b/apps/api/src/services/tag/tag.module.ts @@ -0,0 +1,11 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; +import { Module } from '@nestjs/common'; + +import { TagService } from './tag.service'; + +@Module({ + exports: [TagService], + imports: [PrismaModule], + providers: [TagService] +}) +export class TagModule {} diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts new file mode 100644 index 00000000..534a6e73 --- /dev/null +++ b/apps/api/src/services/tag/tag.service.ts @@ -0,0 +1,30 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class TagService { + public constructor(private readonly prismaService: PrismaService) {} + + public async get() { + return this.prismaService.tag.findMany({ + orderBy: { + name: 'asc' + } + }); + } + + public async getByUser(userId: string) { + return this.prismaService.tag.findMany({ + orderBy: { + name: 'asc' + }, + where: { + orders: { + some: { + userId + } + } + } + }); + } +} diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index da5e0702..ed8d9e19 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -48,6 +48,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { { label: 'Initial', value: 'original' }, { label: 'Current', value: 'current' } ]; + public placeholder = ''; public portfolioDetails: PortfolioDetails; public positions: { [symbol: string]: Pick< @@ -73,6 +74,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { value: number; }; }; + public tags: string[] = []; public user: User; @@ -120,29 +122,22 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { this.hasImpersonationId = !!aId; }); - this.dataService - .fetchPortfolioDetails({}) - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((portfolioDetails) => { - this.portfolioDetails = portfolioDetails; - - this.initializeAnalysisData(this.period); - - this.changeDetectorRef.markForCheck(); - }); - this.userService.stateChanged .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((state) => { if (state?.user) { this.user = state.user; + this.tags = this.user.tags.map((tag) => { + return tag.name; + }); + this.changeDetectorRef.markForCheck(); } }); } - public initializeAnalysisData(aPeriod: string) { + public initialize() { this.accounts = {}; this.continents = { [UNKNOWN_KEY]: { @@ -185,6 +180,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { value: 0 } }; + } + + public initializeAnalysisData(aPeriod: string) { + this.initialize(); for (const [id, { current, name, original }] of Object.entries( this.portfolioDetails.accounts @@ -305,7 +304,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { } } - if (position.assetClass === AssetClass.EQUITY) { + if (position.dataSource) { this.symbols[prettifySymbol(symbol)] = { dataSource: position.dataSource, name: position.name, @@ -342,6 +341,25 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { } } + public onUpdateFilters(tags: string[] = []) { + this.update(tags); + } + + public update(tags?: string[]) { + this.initialize(); + + this.dataService + .fetchPortfolioDetails({ tags }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((portfolioDetails) => { + this.portfolioDetails = portfolioDetails; + + this.initializeAnalysisData(this.period); + + this.changeDetectorRef.markForCheck(); + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html index dac241b1..eebf007d 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -2,6 +2,12 @@

Allocations

+
diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts index 62dd05e4..fc879177 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts @@ -4,6 +4,7 @@ import { MatCardModule } from '@angular/material/card'; import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module'; +import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfValueModule } from '@ghostfolio/ui/value'; @@ -16,6 +17,7 @@ import { AllocationsPageComponent } from './allocations-page.component'; imports: [ AllocationsPageRoutingModule, CommonModule, + GfActivitiesFilterModule, GfPortfolioProportionChartModule, GfPositionsTableModule, GfToggleModule, diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index b51a00ce..c5998282 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -182,9 +182,15 @@ export class DataService { ); } - public fetchPortfolioDetails(aParams: { [param: string]: any }) { + public fetchPortfolioDetails({ tags }: { tags?: string[] }) { + let params = new HttpParams(); + + if (tags?.length > 0) { + params = params.append('tags', tags.join(',')); + } + return this.http.get('/api/v1/portfolio/details', { - params: aParams + params }); } diff --git a/libs/common/src/lib/interfaces/info-item.interface.ts b/libs/common/src/lib/interfaces/info-item.interface.ts index 443bb061..f9d53b18 100644 --- a/libs/common/src/lib/interfaces/info-item.interface.ts +++ b/libs/common/src/lib/interfaces/info-item.interface.ts @@ -1,3 +1,4 @@ +import { Tag } from '@prisma/client'; import { Statistics } from './statistics.interface'; import { Subscription } from './subscription.interface'; @@ -13,4 +14,5 @@ export interface InfoItem { stripePublicKey?: string; subscriptions: Subscription[]; systemMessage?: string; + tags: Tag[]; } diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts index 71288ccd..a6bd3dab 100644 --- a/libs/common/src/lib/interfaces/user.interface.ts +++ b/libs/common/src/lib/interfaces/user.interface.ts @@ -1,5 +1,5 @@ import { Access } from '@ghostfolio/api/app/user/interfaces/access.interface'; -import { Account } from '@prisma/client'; +import { Account, Tag } from '@prisma/client'; import { UserSettings } from './user-settings.interface'; @@ -14,4 +14,5 @@ export interface User { expiresAt?: Date; type: 'Basic' | 'Premium'; }; + tags: Tag[]; } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e220b4eb..db2ee93c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,6 +79,7 @@ model Order { quantity Float SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id]) symbolProfileId String + tags Tag[] type Type unitPrice Float updatedAt DateTime @updatedAt @@ -148,6 +149,12 @@ model Subscription { userId String } +model Tag { + id String @id @default(uuid()) + name String @unique + orders Order[] +} + model User { Access Access[] @relation("accessGet") AccessGive Access[] @relation(name: "accessGive")