Setup allocations page and endpoint (#859)
* Setup tagging system * Update changelog
This commit is contained in:
parent
ea89ca5734
commit
bad9d17c44
@ -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,9 +443,11 @@ export class PortfolioService {
|
||||
value: totalValue
|
||||
});
|
||||
|
||||
if (tags === undefined) {
|
||||
for (const symbol of Object.keys(cashPositions)) {
|
||||
holdings[symbol] = cashPositions[symbol];
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await this.getValueOfAccounts(
|
||||
orders,
|
||||
@ -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,
|
||||
|
11
apps/api/src/services/tag/tag.module.ts
Normal file
11
apps/api/src/services/tag/tag.module.ts
Normal 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 {}
|
30
apps/api/src/services/tag/tag.service.ts
Normal file
30
apps/api/src/services/tag/tag.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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…
x
Reference in New Issue
Block a user