Feature/add public portfolio (#426)
* Setup public portfolio page * Update changelog
This commit is contained in:
parent
43104f81d0
commit
6dea9093ba
@ -7,10 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a public page to share your portfolio
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the skeleton loader size of the portfolio proportion chart component
|
- Improved the skeleton loader size of the portfolio proportion chart component
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn prisma migrate deploy`)
|
||||||
|
|
||||||
## 1.62.0 - 17.10.2021
|
## 1.62.0 - 17.10.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -24,8 +24,18 @@ export class AccessController {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return accessesWithGranteeUser.map((access) => {
|
return accessesWithGranteeUser.map((access) => {
|
||||||
|
if (access.GranteeUser) {
|
||||||
|
return {
|
||||||
|
granteeAlias: access.GranteeUser?.alias,
|
||||||
|
id: access.id,
|
||||||
|
type: 'RESTRICTED_VIEW'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
granteeAlias: access.GranteeUser.alias
|
granteeAlias: 'Public',
|
||||||
|
id: access.id,
|
||||||
|
type: 'PUBLIC'
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
|
|||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
|
||||||
controllers: [AccessController],
|
controllers: [AccessController],
|
||||||
|
exports: [AccessService],
|
||||||
|
imports: [],
|
||||||
providers: [AccessService, PrismaService]
|
providers: [AccessService, PrismaService]
|
||||||
})
|
})
|
||||||
export class AccessModule {}
|
export class AccessModule {}
|
||||||
|
@ -7,6 +7,17 @@ import { Prisma } from '@prisma/client';
|
|||||||
export class AccessService {
|
export class AccessService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async access(
|
||||||
|
accessWhereInput: Prisma.AccessWhereInput
|
||||||
|
): Promise<AccessWithGranteeUser | null> {
|
||||||
|
return this.prismaService.access.findFirst({
|
||||||
|
include: {
|
||||||
|
GranteeUser: true
|
||||||
|
},
|
||||||
|
where: accessWhereInput
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async accesses(params: {
|
public async accesses(params: {
|
||||||
include?: Prisma.AccessInclude;
|
include?: Prisma.AccessInclude;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import {
|
import {
|
||||||
hasNotDefinedValuesInObject,
|
hasNotDefinedValuesInObject,
|
||||||
@ -5,9 +6,11 @@ import {
|
|||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
@ -39,6 +42,7 @@ import { PortfolioService } from './portfolio.service';
|
|||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
export class PortfolioController {
|
export class PortfolioController {
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly accessService: AccessService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly portfolioService: PortfolioService,
|
private readonly portfolioService: PortfolioService,
|
||||||
@ -145,7 +149,11 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(impersonationId, range);
|
await this.portfolioService.getDetails(
|
||||||
|
impersonationId,
|
||||||
|
this.request.user.id,
|
||||||
|
range
|
||||||
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
res.status(StatusCodes.ACCEPTED);
|
res.status(StatusCodes.ACCEPTED);
|
||||||
@ -252,6 +260,59 @@ export class PortfolioController {
|
|||||||
return <any>res.json(result);
|
return <any>res.json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('public/:accessId')
|
||||||
|
public async getPublic(
|
||||||
|
@Param('accessId') accessId,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<PortfolioPublicDetails> {
|
||||||
|
const access = await this.accessService.access({ id: accessId });
|
||||||
|
|
||||||
|
if (!access) {
|
||||||
|
res.status(StatusCodes.NOT_FOUND);
|
||||||
|
return <any>res.json({ accounts: {}, holdings: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hasErrors, holdings } = await this.portfolioService.getDetails(
|
||||||
|
access.userId,
|
||||||
|
access.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||||
|
holdings: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
|
res.status(StatusCodes.ACCEPTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalValue = Object.values(holdings)
|
||||||
|
.filter((holding) => {
|
||||||
|
return holding.assetClass === 'EQUITY';
|
||||||
|
})
|
||||||
|
.map((portfolioPosition) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||||
|
portfolioPosition.currency,
|
||||||
|
this.request.user?.Settings?.currency ?? baseCurrency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||||
|
if (portfolioPosition.assetClass === 'EQUITY') {
|
||||||
|
portfolioPublicDetails.holdings[symbol] = {
|
||||||
|
allocationCurrent: portfolioPosition.allocationCurrent,
|
||||||
|
countries: [],
|
||||||
|
name: portfolioPosition.name,
|
||||||
|
sectors: [],
|
||||||
|
value: portfolioPosition.value / totalValue
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json(portfolioPublicDetails);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getSummary(
|
public async getSummary(
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
@ -18,6 +19,7 @@ import { RulesService } from './rules.service';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
AccessModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
// TODO ///////////
|
||||||
|
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
@ -21,7 +23,11 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
|||||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
|
import {
|
||||||
|
UNKNOWN_KEY,
|
||||||
|
baseCurrency,
|
||||||
|
ghostfolioCashSymbol
|
||||||
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
@ -78,7 +84,7 @@ export class PortfolioService {
|
|||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
aImpersonationId: string
|
aImpersonationId: string
|
||||||
): Promise<InvestmentItem[]> {
|
): Promise<InvestmentItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -106,7 +112,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<HistoricalDataItem[]> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -148,11 +154,12 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
|
aUserId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||||
|
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
userCurrency
|
userCurrency
|
||||||
@ -265,7 +272,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aSymbol: string
|
aSymbol: string
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||||
(order) => order.symbol === aSymbol
|
(order) => order.symbol === aSymbol
|
||||||
@ -484,7 +491,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
): Promise<{ hasErrors: boolean; positions: Position[] }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -555,7 +562,7 @@ export class PortfolioService {
|
|||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -628,8 +635,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||||
const userId = await this.getUserId(impersonationId);
|
const currency = this.request.user.Settings.currency;
|
||||||
const baseCurrency = this.request.user.Settings.currency;
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
|
|
||||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||||
userId
|
userId
|
||||||
@ -643,7 +650,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
this.request.user.Settings.currency
|
currency
|
||||||
);
|
);
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
|
|
||||||
@ -659,7 +666,7 @@ export class PortfolioService {
|
|||||||
const accounts = await this.getAccounts(
|
const accounts = await this.getAccounts(
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
baseCurrency,
|
currency,
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@ -679,7 +686,7 @@ export class PortfolioService {
|
|||||||
accounts
|
accounts
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
),
|
),
|
||||||
currencyClusterRisk: await this.rulesService.evaluate(
|
currencyClusterRisk: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -700,7 +707,7 @@ export class PortfolioService {
|
|||||||
currentPositions
|
currentPositions
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
),
|
),
|
||||||
fees: await this.rulesService.evaluate(
|
fees: await this.rulesService.evaluate(
|
||||||
[
|
[
|
||||||
@ -710,7 +717,7 @@ export class PortfolioService {
|
|||||||
this.getFees(orders)
|
this.getFees(orders)
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency }
|
{ baseCurrency: currency }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -718,7 +725,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const currency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(aImpersonationId);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||||
|
|
||||||
@ -820,7 +827,7 @@ export class PortfolioService {
|
|||||||
return { transactionPoints: [], orders: [] };
|
return { transactionPoints: [], orders: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||||
currency: order.currency,
|
currency: order.currency,
|
||||||
dataSource: order.dataSource,
|
dataSource: order.dataSource,
|
||||||
@ -920,14 +927,14 @@ export class PortfolioService {
|
|||||||
return accounts;
|
return accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserId(aImpersonationId: string) {
|
private async getUserId(aImpersonationId: string, aUserId: string) {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
aImpersonationId,
|
aImpersonationId,
|
||||||
this.request.user.id
|
aUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
return impersonationUserId || this.request.user.id;
|
return impersonationUserId || aUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getTotalByType(
|
private getTotalByType(
|
||||||
|
@ -52,6 +52,13 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'p',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/public/public-page.module').then(
|
||||||
|
(m) => m.PublicPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'portfolio',
|
path: 'portfolio',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -9,8 +9,14 @@
|
|||||||
<ng-container matColumnDef="type">
|
<ng-container matColumnDef="type">
|
||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
<ng-container *ngIf="element.type === 'PUBLIC'">
|
||||||
Restricted View
|
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
||||||
|
{{ baseUrl }}/p/{{ element.id }}
|
||||||
|
</ng-container>
|
||||||
|
<ng-container *ngIf="element.type === 'RESTRICTED_VIEW'">
|
||||||
|
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||||
|
Restricted View
|
||||||
|
</ng-container>
|
||||||
</td></ng-container
|
</td></ng-container
|
||||||
>
|
>
|
||||||
|
|
||||||
|
@ -17,6 +17,7 @@ import { Access } from '@ghostfolio/common/interfaces';
|
|||||||
export class AccessTableComponent implements OnChanges, OnInit {
|
export class AccessTableComponent implements OnChanges, OnInit {
|
||||||
@Input() accesses: Access[];
|
@Input() accesses: Access[];
|
||||||
|
|
||||||
|
public baseUrl = window.location.origin;
|
||||||
public dataSource: MatTableDataSource<Access>;
|
public dataSource: MatTableDataSource<Access>;
|
||||||
public displayedColumns = ['granteeAlias', 'type'];
|
public displayedColumns = ['granteeAlias', 'type'];
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
'/about',
|
'/about',
|
||||||
'/de/blog',
|
'/de/blog',
|
||||||
'/en/blog',
|
'/en/blog',
|
||||||
|
'/p',
|
||||||
'/pricing',
|
'/pricing',
|
||||||
'/register',
|
'/register',
|
||||||
'/resources'
|
'/resources'
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
|
import { PublicPageComponent } from './public-page.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: ':id', component: PublicPageComponent, canActivate: [AuthGuard] }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class PublicPageRoutingModule {}
|
183
apps/client/src/app/pages/public/public-page.component.ts
Normal file
183
apps/client/src/app/pages/public/public-page.component.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import {
|
||||||
|
PortfolioPosition,
|
||||||
|
PortfolioPublicDetails
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { StatusCodes } from 'http-status-codes';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
|
selector: 'gf-public-page',
|
||||||
|
styleUrls: ['./public-page.scss'],
|
||||||
|
templateUrl: './public-page.html'
|
||||||
|
})
|
||||||
|
export class PublicPageComponent implements OnInit {
|
||||||
|
public continents: {
|
||||||
|
[code: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public countries: {
|
||||||
|
[code: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public deviceType: string;
|
||||||
|
public portfolioPublicDetails: PortfolioPublicDetails;
|
||||||
|
public positions: {
|
||||||
|
[symbol: string]: Pick<PortfolioPosition, 'name' | 'value'>;
|
||||||
|
};
|
||||||
|
public sectors: {
|
||||||
|
[name: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public symbols: {
|
||||||
|
[name: string]: { name: string; symbol: string; value: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
private id: string;
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private activatedRoute: ActivatedRoute,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private router: Router
|
||||||
|
) {
|
||||||
|
this.activatedRoute.params.subscribe((params) => {
|
||||||
|
this.id = params['id'];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchPortfolioPublic(this.id)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError((error) => {
|
||||||
|
if (error.status === StatusCodes.NOT_FOUND) {
|
||||||
|
console.error(error);
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((portfolioPublicDetails) => {
|
||||||
|
this.portfolioPublicDetails = portfolioPublicDetails;
|
||||||
|
|
||||||
|
this.initializeAnalysisData();
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public initializeAnalysisData() {
|
||||||
|
this.continents = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.countries = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.positions = {};
|
||||||
|
this.sectors = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.symbols = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
symbol: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [symbol, position] of Object.entries(
|
||||||
|
this.portfolioPublicDetails.holdings
|
||||||
|
)) {
|
||||||
|
const value = position.allocationCurrent;
|
||||||
|
|
||||||
|
this.positions[symbol] = {
|
||||||
|
value,
|
||||||
|
name: position.name
|
||||||
|
};
|
||||||
|
|
||||||
|
if (position.countries.length > 0) {
|
||||||
|
for (const country of position.countries) {
|
||||||
|
const { code, continent, name, weight } = country;
|
||||||
|
|
||||||
|
if (this.continents[continent]?.value) {
|
||||||
|
this.continents[continent].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.continents[continent] = {
|
||||||
|
name: continent,
|
||||||
|
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.countries[code]?.value) {
|
||||||
|
this.countries[code].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.countries[code] = {
|
||||||
|
name,
|
||||||
|
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.continents[UNKNOWN_KEY].value +=
|
||||||
|
this.portfolioPublicDetails.holdings[symbol].value;
|
||||||
|
|
||||||
|
this.countries[UNKNOWN_KEY].value +=
|
||||||
|
this.portfolioPublicDetails.holdings[symbol].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position.sectors.length > 0) {
|
||||||
|
for (const sector of position.sectors) {
|
||||||
|
const { name, weight } = sector;
|
||||||
|
|
||||||
|
if (this.sectors[name]?.value) {
|
||||||
|
this.sectors[name].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.sectors[name] = {
|
||||||
|
name,
|
||||||
|
value: weight * this.portfolioPublicDetails.holdings[symbol].value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.sectors[UNKNOWN_KEY].value +=
|
||||||
|
this.portfolioPublicDetails.holdings[symbol].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.symbols[symbol] = {
|
||||||
|
symbol,
|
||||||
|
name: position.name,
|
||||||
|
value: position.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
38
apps/client/src/app/pages/public/public-page.html
Normal file
38
apps/client/src/app/pages/public/public-page.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h3 class="d-flex justify-content-center mb-3" i18n>Portfolio</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="proportion-charts row">
|
||||||
|
<div class="col-md-12 allocations-by-symbol">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-portfolio-proportion-chart
|
||||||
|
class="mx-auto"
|
||||||
|
[isInPercent]="true"
|
||||||
|
[keys]="['symbol']"
|
||||||
|
[positions]="symbols"
|
||||||
|
[showLabels]="deviceType !== 'mobile'"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row my-5">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<h2 class="h4 mb-1 text-center">
|
||||||
|
Would you like to <strong>refine</strong> your
|
||||||
|
<strong>personal investment strategy</strong>?
|
||||||
|
</h2>
|
||||||
|
<p class="lead mb-3 text-center" i18n>
|
||||||
|
Ghostfolio empowers you to keep track of your wealth.
|
||||||
|
</p>
|
||||||
|
<div class="py-2 text-center">
|
||||||
|
<a color="primary" i18n mat-flat-button [routerLink]="['/']">
|
||||||
|
Get Started
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
23
apps/client/src/app/pages/public/public-page.module.ts
Normal file
23
apps/client/src/app/pages/public/public-page.module.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||||
|
|
||||||
|
import { PublicPageRoutingModule } from './public-page-routing.module';
|
||||||
|
import { PublicPageComponent } from './public-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [PublicPageComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfPortfolioProportionChartModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
PublicPageRoutingModule
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class PublicPageModule {}
|
12
apps/client/src/app/pages/public/public-page.scss
Normal file
12
apps/client/src/app/pages/public/public-page.scss
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
gf-portfolio-proportion-chart {
|
||||||
|
max-width: 80vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -22,6 +22,7 @@ import {
|
|||||||
InfoItem,
|
InfoItem,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
User
|
User
|
||||||
@ -164,6 +165,12 @@ export class DataService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fetchPortfolioPublic(aId: string) {
|
||||||
|
return this.http.get<PortfolioPublicDetails>(
|
||||||
|
`/api/portfolio/public/${aId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public fetchPortfolioReport() {
|
public fetchPortfolioReport() {
|
||||||
return this.http.get<PortfolioReport>('/api/portfolio/report');
|
return this.http.get<PortfolioReport>('/api/portfolio/report');
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
export interface Access {
|
export interface Access {
|
||||||
granteeAlias: string;
|
granteeAlias: string;
|
||||||
|
id: string;
|
||||||
|
type: 'PUBLIC' | 'RESTRICTED_VIEW';
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import { PortfolioItem } from './portfolio-item.interface';
|
|||||||
import { PortfolioOverview } from './portfolio-overview.interface';
|
import { PortfolioOverview } from './portfolio-overview.interface';
|
||||||
import { PortfolioPerformance } from './portfolio-performance.interface';
|
import { PortfolioPerformance } from './portfolio-performance.interface';
|
||||||
import { PortfolioPosition } from './portfolio-position.interface';
|
import { PortfolioPosition } from './portfolio-position.interface';
|
||||||
|
import { PortfolioPublicDetails } from './portfolio-public-details.interface';
|
||||||
import { PortfolioReportRule } from './portfolio-report-rule.interface';
|
import { PortfolioReportRule } from './portfolio-report-rule.interface';
|
||||||
import { PortfolioReport } from './portfolio-report.interface';
|
import { PortfolioReport } from './portfolio-report.interface';
|
||||||
import { PortfolioSummary } from './portfolio-summary.interface';
|
import { PortfolioSummary } from './portfolio-summary.interface';
|
||||||
@ -26,6 +27,7 @@ export {
|
|||||||
PortfolioOverview,
|
PortfolioOverview,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioReportRule,
|
PortfolioReportRule,
|
||||||
PortfolioSummary,
|
PortfolioSummary,
|
||||||
|
@ -0,0 +1,10 @@
|
|||||||
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
|
export interface PortfolioPublicDetails {
|
||||||
|
holdings: {
|
||||||
|
[symbol: string]: Pick<
|
||||||
|
PortfolioPosition,
|
||||||
|
'allocationCurrent' | 'countries' | 'name' | 'sectors' | 'value'
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Access" ALTER COLUMN "granteeUserId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Account" ALTER COLUMN "currency" DROP NOT NULL;
|
@ -14,8 +14,8 @@ generator client {
|
|||||||
|
|
||||||
model Access {
|
model Access {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
GranteeUser User @relation(fields: [granteeUserId], name: "accessGet", references: [id])
|
GranteeUser User? @relation(fields: [granteeUserId], name: "accessGet", references: [id])
|
||||||
granteeUserId String
|
granteeUserId String?
|
||||||
id String @default(uuid())
|
id String @default(uuid())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
User User @relation(fields: [userId], name: "accessGive", references: [id])
|
User User @relation(fields: [userId], name: "accessGive", references: [id])
|
||||||
|
Loading…
x
Reference in New Issue
Block a user