Setup allocations page and endpoint (#859)

* Setup tagging system

* Update changelog
pull/862/head
Thomas Kaul 2 years ago committed by GitHub
parent ea89ca5734
commit bad9d17c44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

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

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

@ -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<PortfolioDetails & { hasError: boolean }> {
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')

@ -303,7 +303,8 @@ export class PortfolioService {
public async getDetails(
aImpersonationId: string,
aUserId: string,
aDateRange: DateRange = 'max'
aDateRange: DateRange = 'max',
tags?: string[]
): Promise<PortfolioDetails & { hasErrors: boolean }> {
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']

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

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

@ -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,

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

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

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

@ -2,6 +2,12 @@
<div class="row">
<div class="col">
<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 class="proportion-charts row">

@ -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,

@ -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', {
params: aParams
params
});
}

@ -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[];
}

@ -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[];
}

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

Loading…
Cancel
Save