Setup allocations page and endpoint (#859)

* Setup tagging system

* Update changelog
This commit is contained in:
Thomas Kaul 2022-04-24 16:23:03 +02:00 committed by GitHub
parent ea89ca5734
commit bad9d17c44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 160 additions and 29 deletions

View File

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added a tagging system for activities
### Changed ### Changed
- Extracted the activities table filter to a dedicated component - Extracted the activities table filter to a dedicated component

View File

@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.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 { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -26,7 +27,8 @@ import { InfoService } from './info.service';
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule,
TagModule
], ],
providers: [InfoService] providers: [InfoService]
}) })

View File

@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEMO_USER_ID, DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
@ -33,7 +34,8 @@ export class InfoService {
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
@ -105,7 +107,8 @@ export class InfoService {
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(), statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions() subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
}; };
} }

View File

@ -152,11 +152,13 @@ export class OrderService {
public async getOrders({ public async getOrders({
includeDrafts = false, includeDrafts = false,
tags,
types, types,
userCurrency, userCurrency,
userId userId
}: { }: {
includeDrafts?: boolean; includeDrafts?: boolean;
tags?: string[];
types?: TypeOfOrder[]; types?: TypeOfOrder[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
@ -167,6 +169,18 @@ export class OrderService {
where.isDraft = false; where.isDraft = false;
} }
if (tags?.length > 0) {
where.tags = {
some: {
OR: tags.map((tag) => {
return {
name: tag
};
})
}
};
}
if (types) { if (types) {
where.OR = types.map((type) => { where.OR = types.map((type) => {
return { return {

View File

@ -105,7 +105,8 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') range,
@Query('tags') tags?: string
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
let hasError = false; let hasError = false;
@ -113,7 +114,8 @@ export class PortfolioController {
await this.portfolioService.getDetails( await this.portfolioService.getDetails(
impersonationId, impersonationId,
this.request.user.id, this.request.user.id,
range range,
tags?.split(',')
); );
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -159,7 +161,11 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'; this.request.user.subscription.type === 'Basic';
return { accounts, hasError, holdings: isBasicUser ? {} : holdings }; return {
hasError,
accounts: tags ? {} : accounts,
holdings: isBasicUser ? {} : holdings
};
} }
@Get('investments') @Get('investments')

View File

@ -303,7 +303,8 @@ export class PortfolioService {
public async getDetails( public async getDetails(
aImpersonationId: string, aImpersonationId: string,
aUserId: string, aUserId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max',
tags?: string[]
): Promise<PortfolioDetails & { hasErrors: boolean }> { ): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId, aUserId); const userId = await this.getUserId(aImpersonationId, aUserId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -318,6 +319,7 @@ export class PortfolioService {
const { orders, portfolioOrders, transactionPoints } = const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
tags,
userId userId
}); });
@ -441,8 +443,10 @@ export class PortfolioService {
value: totalValue value: totalValue
}); });
for (const symbol of Object.keys(cashPositions)) { if (tags === undefined) {
holdings[symbol] = cashPositions[symbol]; for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
} }
const accounts = await this.getValueOfAccounts( const accounts = await this.getValueOfAccounts(
@ -1178,9 +1182,11 @@ export class PortfolioService {
private async getTransactionPoints({ private async getTransactionPoints({
includeDrafts = false, includeDrafts = false,
tags,
userId userId
}: { }: {
includeDrafts?: boolean; includeDrafts?: boolean;
tags?: string[];
userId: string; userId: string;
}): Promise<{ }): Promise<{
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
@ -1191,6 +1197,7 @@ export class PortfolioService {
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
includeDrafts, includeDrafts,
tags,
userCurrency, userCurrency,
userId, userId,
types: ['BUY', 'SELL'] types: ['BUY', 'SELL']

View File

@ -34,7 +34,7 @@ import { UserService } from './user.service';
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private jwtService: JwtService, private readonly jwtService: JwtService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService

View File

@ -2,6 +2,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.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 { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -19,7 +20,8 @@ import { UserService } from './user.service';
}), }),
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,
SubscriptionModule SubscriptionModule,
TagModule
], ],
providers: [UserService] providers: [UserService]
}) })

View File

@ -2,6 +2,7 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
baseCurrency, baseCurrency,
@ -13,7 +14,6 @@ import {
hasRole, hasRole,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client'; import { Prisma, Role, User, ViewMode } from '@prisma/client';
@ -30,7 +30,8 @@ export class UserService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService
) {} ) {}
public async getUser( public async getUser(
@ -51,12 +52,21 @@ export class UserService {
orderBy: { User: { alias: 'asc' } }, orderBy: { User: { alias: 'asc' } },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
let tags = await this.tagService.getByUser(id);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === 'Basic'
) {
tags = [];
}
return { return {
alias, alias,
id, id,
permissions, permissions,
subscription, subscription,
tags,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.User.alias, alias: accessItem.User.alias,

View File

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

View File

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

View File

@ -48,6 +48,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
{ label: 'Initial', value: 'original' }, { label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' } { label: 'Current', value: 'current' }
]; ];
public placeholder = '';
public portfolioDetails: PortfolioDetails; public portfolioDetails: PortfolioDetails;
public positions: { public positions: {
[symbol: string]: Pick< [symbol: string]: Pick<
@ -73,6 +74,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number; value: number;
}; };
}; };
public tags: string[] = [];
public user: User; public user: User;
@ -120,29 +122,22 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId; 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 this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.tags = this.user.tags.map((tag) => {
return tag.name;
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
} }
public initializeAnalysisData(aPeriod: string) { public initialize() {
this.accounts = {}; this.accounts = {};
this.continents = { this.continents = {
[UNKNOWN_KEY]: { [UNKNOWN_KEY]: {
@ -185,6 +180,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: 0 value: 0
} }
}; };
}
public initializeAnalysisData(aPeriod: string) {
this.initialize();
for (const [id, { current, name, original }] of Object.entries( for (const [id, { current, name, original }] of Object.entries(
this.portfolioDetails.accounts 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)] = { this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource, dataSource: position.dataSource,
name: position.name, 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() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -2,6 +2,12 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3>
<gf-activities-filter
[allFilters]="tags"
[ngClass]="{ 'd-none': tags.length <= 0 }"
[placeholder]="placeholder"
(valueChanged)="onUpdateFilters($event)"
></gf-activities-filter>
</div> </div>
</div> </div>
<div class="proportion-charts row"> <div class="proportion-charts row">

View File

@ -4,6 +4,7 @@ import { MatCardModule } from '@angular/material/card';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module'; import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.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 { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -16,6 +17,7 @@ import { AllocationsPageComponent } from './allocations-page.component';
imports: [ imports: [
AllocationsPageRoutingModule, AllocationsPageRoutingModule,
CommonModule, CommonModule,
GfActivitiesFilterModule,
GfPortfolioProportionChartModule, GfPortfolioProportionChartModule,
GfPositionsTableModule, GfPositionsTableModule,
GfToggleModule, GfToggleModule,

View File

@ -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<PortfolioDetails>('/api/v1/portfolio/details', { return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', {
params: aParams params
}); });
} }

View File

@ -1,3 +1,4 @@
import { Tag } from '@prisma/client';
import { Statistics } from './statistics.interface'; import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface'; import { Subscription } from './subscription.interface';
@ -13,4 +14,5 @@ export interface InfoItem {
stripePublicKey?: string; stripePublicKey?: string;
subscriptions: Subscription[]; subscriptions: Subscription[];
systemMessage?: string; systemMessage?: string;
tags: Tag[];
} }

View File

@ -1,5 +1,5 @@
import { Access } from '@ghostfolio/api/app/user/interfaces/access.interface'; 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'; import { UserSettings } from './user-settings.interface';
@ -14,4 +14,5 @@ export interface User {
expiresAt?: Date; expiresAt?: Date;
type: 'Basic' | 'Premium'; type: 'Basic' | 'Premium';
}; };
tags: Tag[];
} }

View File

@ -79,6 +79,7 @@ model Order {
quantity Float quantity Float
SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id]) SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id])
symbolProfileId String symbolProfileId String
tags Tag[]
type Type type Type
unitPrice Float unitPrice Float
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -148,6 +149,12 @@ model Subscription {
userId String userId String
} }
model Tag {
id String @id @default(uuid())
name String @unique
orders Order[]
}
model User { model User {
Access Access[] @relation("accessGet") Access Access[] @relation("accessGet")
AccessGive Access[] @relation(name: "accessGive") AccessGive Access[] @relation(name: "accessGive")