mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
821 changed files with 116550 additions and 24849 deletions
@ -0,0 +1 @@ |
|||
14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51 |
@ -0,0 +1 @@ |
|||
v18 |
@ -1,11 +0,0 @@ |
|||
module.exports = { |
|||
stories: [], |
|||
addons: ['@storybook/addon-essentials'] |
|||
// uncomment the property below if you want to apply some webpack config globally
|
|||
// webpackFinal: async (config, { configType }) => {
|
|||
// // Make whatever fine-grained changes you need that should apply to all storybook configs
|
|||
|
|||
// // Return the altered config
|
|||
// return config;
|
|||
// },
|
|||
}; |
@ -1,10 +0,0 @@ |
|||
{ |
|||
"extends": "../tsconfig.base.json", |
|||
"exclude": [ |
|||
"../**/*.spec.js", |
|||
"../**/*.spec.ts", |
|||
"../**/*.spec.tsx", |
|||
"../**/*.spec.jsx" |
|||
], |
|||
"include": ["../**/*"] |
|||
} |
File diff suppressed because it is too large
@ -0,0 +1,37 @@ |
|||
# Ghostfolio Development Guide |
|||
|
|||
## Git |
|||
|
|||
### Rebase |
|||
|
|||
`git rebase -i --autosquash main` |
|||
|
|||
## Dependencies |
|||
|
|||
### Nx |
|||
|
|||
#### Upgrade |
|||
|
|||
1. Run `yarn nx migrate latest` |
|||
1. Make sure `package.json` changes make sense and then run `yarn install` |
|||
1. Run `yarn nx migrate --run-migrations` |
|||
|
|||
### Prisma |
|||
|
|||
#### Access database via GUI |
|||
|
|||
Run `yarn database:gui` |
|||
|
|||
https://www.prisma.io/studio |
|||
|
|||
#### Synchronize schema with database for prototyping |
|||
|
|||
Run `yarn database:push` |
|||
|
|||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push |
|||
|
|||
#### Create schema migration |
|||
|
|||
Run `yarn prisma migrate dev --name added_job_title` |
|||
|
|||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate |
@ -0,0 +1,12 @@ |
|||
import { IsNumber, IsString } from 'class-validator'; |
|||
|
|||
export class TransferBalanceDto { |
|||
@IsString() |
|||
accountIdFrom: string; |
|||
|
|||
@IsString() |
|||
accountIdTo: string; |
|||
|
|||
@IsNumber() |
|||
balance: number; |
|||
} |
@ -0,0 +1,18 @@ |
|||
import { Prisma } from '@prisma/client'; |
|||
import { IsObject, IsOptional, IsString } from 'class-validator'; |
|||
|
|||
export class UpdateAssetProfileDto { |
|||
@IsString() |
|||
@IsOptional() |
|||
comment?: string; |
|||
|
|||
@IsObject() |
|||
@IsOptional() |
|||
scraperConfiguration?: Prisma.InputJsonObject; |
|||
|
|||
@IsObject() |
|||
@IsOptional() |
|||
symbolMapping?: { |
|||
[dataProvider: string]: string; |
|||
}; |
|||
} |
@ -0,0 +1,43 @@ |
|||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; |
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Param, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { parseISO } from 'date-fns'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { ExchangeRateService } from './exchange-rate.service'; |
|||
|
|||
@Controller('exchange-rate') |
|||
export class ExchangeRateController { |
|||
public constructor( |
|||
private readonly exchangeRateService: ExchangeRateService |
|||
) {} |
|||
|
|||
@Get(':symbol/:dateString') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getExchangeRate( |
|||
@Param('dateString') dateString: string, |
|||
@Param('symbol') symbol: string |
|||
): Promise<IDataProviderHistoricalResponse> { |
|||
const date = parseISO(dateString); |
|||
|
|||
const exchangeRate = await this.exchangeRateService.getExchangeRate({ |
|||
date, |
|||
symbol |
|||
}); |
|||
|
|||
if (exchangeRate) { |
|||
return { marketPrice: exchangeRate }; |
|||
} |
|||
|
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
} |
@ -0,0 +1,13 @@ |
|||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { ExchangeRateController } from './exchange-rate.controller'; |
|||
import { ExchangeRateService } from './exchange-rate.service'; |
|||
|
|||
@Module({ |
|||
controllers: [ExchangeRateController], |
|||
exports: [ExchangeRateService], |
|||
imports: [ExchangeRateDataModule], |
|||
providers: [ExchangeRateService] |
|||
}) |
|||
export class ExchangeRateModule {} |
@ -0,0 +1,26 @@ |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { Injectable } from '@nestjs/common'; |
|||
|
|||
@Injectable() |
|||
export class ExchangeRateService { |
|||
public constructor( |
|||
private readonly exchangeRateDataService: ExchangeRateDataService |
|||
) {} |
|||
|
|||
public async getExchangeRate({ |
|||
date, |
|||
symbol |
|||
}: { |
|||
date: Date; |
|||
symbol: string; |
|||
}): Promise<number> { |
|||
const [currency1, currency2] = symbol.split('-'); |
|||
|
|||
return this.exchangeRateDataService.toCurrencyAtDate( |
|||
1, |
|||
currency1, |
|||
currency2, |
|||
date |
|||
); |
|||
} |
|||
} |
@ -1,146 +0,0 @@ |
|||
import * as fs from 'fs'; |
|||
import * as path from 'path'; |
|||
|
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; |
|||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; |
|||
import { Injectable, NestMiddleware } from '@nestjs/common'; |
|||
import { ConfigService } from '@nestjs/config'; |
|||
import { NextFunction, Request, Response } from 'express'; |
|||
|
|||
@Injectable() |
|||
export class FrontendMiddleware implements NestMiddleware { |
|||
public indexHtmlDe = ''; |
|||
public indexHtmlEn = ''; |
|||
public indexHtmlEs = ''; |
|||
public indexHtmlIt = ''; |
|||
public indexHtmlNl = ''; |
|||
public isProduction: boolean; |
|||
|
|||
public constructor( |
|||
private readonly configService: ConfigService, |
|||
private readonly configurationService: ConfigurationService |
|||
) { |
|||
const NODE_ENV = |
|||
this.configService.get<'development' | 'production'>('NODE_ENV') ?? |
|||
'development'; |
|||
|
|||
this.isProduction = NODE_ENV === 'production'; |
|||
|
|||
try { |
|||
this.indexHtmlDe = fs.readFileSync( |
|||
this.getPathOfIndexHtmlFile('de'), |
|||
'utf8' |
|||
); |
|||
this.indexHtmlEn = fs.readFileSync( |
|||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE), |
|||
'utf8' |
|||
); |
|||
this.indexHtmlEs = fs.readFileSync( |
|||
this.getPathOfIndexHtmlFile('es'), |
|||
'utf8' |
|||
); |
|||
this.indexHtmlIt = fs.readFileSync( |
|||
this.getPathOfIndexHtmlFile('it'), |
|||
'utf8' |
|||
); |
|||
this.indexHtmlNl = fs.readFileSync( |
|||
this.getPathOfIndexHtmlFile('nl'), |
|||
'utf8' |
|||
); |
|||
} catch {} |
|||
} |
|||
|
|||
public use(req: Request, res: Response, next: NextFunction) { |
|||
let featureGraphicPath = 'assets/cover.png'; |
|||
|
|||
if ( |
|||
req.path === '/en/blog/2022/08/500-stars-on-github' || |
|||
req.path === '/en/blog/2022/08/500-stars-on-github/' |
|||
) { |
|||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg'; |
|||
} else if ( |
|||
req.path === '/en/blog/2022/10/hacktoberfest-2022' || |
|||
req.path === '/en/blog/2022/10/hacktoberfest-2022/' |
|||
) { |
|||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png'; |
|||
} |
|||
|
|||
if ( |
|||
req.path.startsWith('/api/') || |
|||
this.isFileRequest(req.url) || |
|||
!this.isProduction |
|||
) { |
|||
// Skip
|
|||
next(); |
|||
} else if (req.path === '/de' || req.path.startsWith('/de/')) { |
|||
res.send( |
|||
this.interpolate(this.indexHtmlDe, { |
|||
featureGraphicPath, |
|||
languageCode: 'de', |
|||
path: req.path, |
|||
rootUrl: this.configurationService.get('ROOT_URL') |
|||
}) |
|||
); |
|||
} else if (req.path === '/es' || req.path.startsWith('/es/')) { |
|||
res.send( |
|||
this.interpolate(this.indexHtmlEs, { |
|||
featureGraphicPath, |
|||
languageCode: 'es', |
|||
path: req.path, |
|||
rootUrl: this.configurationService.get('ROOT_URL') |
|||
}) |
|||
); |
|||
} else if (req.path === '/it' || req.path.startsWith('/it/')) { |
|||
res.send( |
|||
this.interpolate(this.indexHtmlIt, { |
|||
featureGraphicPath, |
|||
languageCode: 'it', |
|||
path: req.path, |
|||
rootUrl: this.configurationService.get('ROOT_URL') |
|||
}) |
|||
); |
|||
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) { |
|||
res.send( |
|||
this.interpolate(this.indexHtmlNl, { |
|||
featureGraphicPath, |
|||
languageCode: 'nl', |
|||
path: req.path, |
|||
rootUrl: this.configurationService.get('ROOT_URL') |
|||
}) |
|||
); |
|||
} else { |
|||
res.send( |
|||
this.interpolate(this.indexHtmlEn, { |
|||
featureGraphicPath, |
|||
languageCode: DEFAULT_LANGUAGE_CODE, |
|||
path: req.path, |
|||
rootUrl: this.configurationService.get('ROOT_URL') |
|||
}) |
|||
); |
|||
} |
|||
} |
|||
|
|||
private getPathOfIndexHtmlFile(aLocale: string) { |
|||
return path.join(__dirname, '..', 'client', aLocale, 'index.html'); |
|||
} |
|||
|
|||
private interpolate(template: string, context: any) { |
|||
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => { |
|||
const properties = objectPath.split('.'); |
|||
return properties.reduce( |
|||
(previous, current) => previous?.[current], |
|||
context |
|||
); |
|||
}); |
|||
} |
|||
|
|||
private isFileRequest(filename: string) { |
|||
if (filename === '/assets/LICENSE') { |
|||
return true; |
|||
} else if (filename.includes('auth/ey')) { |
|||
return false; |
|||
} |
|||
|
|||
return filename.split('.').pop() !== filename; |
|||
} |
|||
} |
@ -0,0 +1,56 @@ |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; |
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Param, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { HealthService } from './health.service'; |
|||
|
|||
@Controller('health') |
|||
export class HealthController { |
|||
public constructor(private readonly healthService: HealthService) {} |
|||
|
|||
@Get() |
|||
public async getHealth() {} |
|||
|
|||
@Get('data-enhancer/:name') |
|||
public async getHealthOfDataEnhancer(@Param('name') name: string) { |
|||
const hasResponse = |
|||
await this.healthService.hasResponseFromDataEnhancer(name); |
|||
|
|||
if (hasResponse !== true) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), |
|||
StatusCodes.SERVICE_UNAVAILABLE |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('data-provider/:dataSource') |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async getHealthOfDataProvider( |
|||
@Param('dataSource') dataSource: DataSource |
|||
) { |
|||
if (!DataSource[dataSource]) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const hasResponse = |
|||
await this.healthService.hasResponseFromDataProvider(dataSource); |
|||
|
|||
if (hasResponse !== true) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), |
|||
StatusCodes.SERVICE_UNAVAILABLE |
|||
); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,14 @@ |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; |
|||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { HealthController } from './health.controller'; |
|||
import { HealthService } from './health.service'; |
|||
|
|||
@Module({ |
|||
controllers: [HealthController], |
|||
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule], |
|||
providers: [HealthService] |
|||
}) |
|||
export class HealthModule {} |
@ -0,0 +1,20 @@ |
|||
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
|
|||
@Injectable() |
|||
export class HealthService { |
|||
public constructor( |
|||
private readonly dataEnhancerService: DataEnhancerService, |
|||
private readonly dataProviderService: DataProviderService |
|||
) {} |
|||
|
|||
public async hasResponseFromDataEnhancer(aName: string) { |
|||
return this.dataEnhancerService.enhance(aName); |
|||
} |
|||
|
|||
public async hasResponseFromDataProvider(aDataSource: DataSource) { |
|||
return this.dataProviderService.checkQuote(aDataSource); |
|||
} |
|||
} |
@ -0,0 +1,54 @@ |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; |
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpStatus, |
|||
Param, |
|||
Query, |
|||
Res, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { Response } from 'express'; |
|||
|
|||
import { LogoService } from './logo.service'; |
|||
|
|||
@Controller('logo') |
|||
export class LogoController { |
|||
public constructor(private readonly logoService: LogoService) {} |
|||
|
|||
@Get(':dataSource/:symbol') |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async getLogoByDataSourceAndSymbol( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string, |
|||
@Res() response: Response |
|||
) { |
|||
try { |
|||
const buffer = await this.logoService.getLogoByDataSourceAndSymbol({ |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
|
|||
response.contentType('image/png'); |
|||
response.send(buffer); |
|||
} catch { |
|||
response.status(HttpStatus.NOT_FOUND).send(); |
|||
} |
|||
} |
|||
|
|||
@Get() |
|||
public async getLogoByUrl( |
|||
@Query('url') url: string, |
|||
@Res() response: Response |
|||
) { |
|||
try { |
|||
const buffer = await this.logoService.getLogoByUrl(url); |
|||
|
|||
response.contentType('image/png'); |
|||
response.send(buffer); |
|||
} catch { |
|||
response.status(HttpStatus.NOT_FOUND).send(); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,13 @@ |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { LogoController } from './logo.controller'; |
|||
import { LogoService } from './logo.service'; |
|||
|
|||
@Module({ |
|||
controllers: [LogoController], |
|||
imports: [ConfigurationModule, SymbolProfileModule], |
|||
providers: [LogoService] |
|||
}) |
|||
export class LogoModule {} |
@ -0,0 +1,60 @@ |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config'; |
|||
import { UniqueAsset } from '@ghostfolio/common/interfaces'; |
|||
import { HttpException, Injectable } from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import got from 'got'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
@Injectable() |
|||
export class LogoService { |
|||
public constructor( |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
public async getLogoByDataSourceAndSymbol({ |
|||
dataSource, |
|||
symbol |
|||
}: UniqueAsset) { |
|||
if (!DataSource[dataSource]) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfile) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return this.getBuffer(assetProfile.url); |
|||
} |
|||
|
|||
public async getLogoByUrl(aUrl: string) { |
|||
return this.getBuffer(aUrl); |
|||
} |
|||
|
|||
private getBuffer(aUrl: string) { |
|||
const abortController = new AbortController(); |
|||
|
|||
setTimeout(() => { |
|||
abortController.abort(); |
|||
}, DEFAULT_REQUEST_TIMEOUT); |
|||
|
|||
return got( |
|||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, |
|||
{ |
|||
headers: { 'User-Agent': 'request' }, |
|||
// @ts-ignore
|
|||
signal: abortController.signal |
|||
} |
|||
).buffer(); |
|||
} |
|||
} |
@ -0,0 +1,9 @@ |
|||
import { IsString } from 'class-validator'; |
|||
|
|||
export class CreatePlatformDto { |
|||
@IsString() |
|||
name: string; |
|||
|
|||
@IsString() |
|||
url: string; |
|||
} |
@ -0,0 +1,115 @@ |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
Put, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { Platform } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { CreatePlatformDto } from './create-platform.dto'; |
|||
import { PlatformService } from './platform.service'; |
|||
import { UpdatePlatformDto } from './update-platform.dto'; |
|||
|
|||
@Controller('platform') |
|||
export class PlatformController { |
|||
public constructor( |
|||
private readonly platformService: PlatformService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async getPlatforms() { |
|||
return this.platformService.getPlatformsWithAccountCount(); |
|||
} |
|||
|
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async createPlatform( |
|||
@Body() data: CreatePlatformDto |
|||
): Promise<Platform> { |
|||
if ( |
|||
!hasPermission(this.request.user.permissions, permissions.createPlatform) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.platformService.createPlatform(data); |
|||
} |
|||
|
|||
@Put(':id') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async updatePlatform( |
|||
@Param('id') id: string, |
|||
@Body() data: UpdatePlatformDto |
|||
) { |
|||
if ( |
|||
!hasPermission(this.request.user.permissions, permissions.updatePlatform) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
const originalPlatform = await this.platformService.getPlatform({ |
|||
id |
|||
}); |
|||
|
|||
if (!originalPlatform) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.platformService.updatePlatform({ |
|||
data: { |
|||
...data |
|||
}, |
|||
where: { |
|||
id |
|||
} |
|||
}); |
|||
} |
|||
|
|||
@Delete(':id') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async deletePlatform(@Param('id') id: string) { |
|||
if ( |
|||
!hasPermission(this.request.user.permissions, permissions.deletePlatform) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
const originalPlatform = await this.platformService.getPlatform({ |
|||
id |
|||
}); |
|||
|
|||
if (!originalPlatform) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.platformService.deletePlatform({ id }); |
|||
} |
|||
} |
@ -0,0 +1,13 @@ |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { PlatformController } from './platform.controller'; |
|||
import { PlatformService } from './platform.service'; |
|||
|
|||
@Module({ |
|||
controllers: [PlatformController], |
|||
exports: [PlatformService], |
|||
imports: [PrismaModule], |
|||
providers: [PlatformService] |
|||
}) |
|||
export class PlatformModule {} |
@ -0,0 +1,83 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Platform, Prisma } from '@prisma/client'; |
|||
|
|||
@Injectable() |
|||
export class PlatformService { |
|||
public constructor(private readonly prismaService: PrismaService) {} |
|||
|
|||
public async createPlatform(data: Prisma.PlatformCreateInput) { |
|||
return this.prismaService.platform.create({ |
|||
data |
|||
}); |
|||
} |
|||
|
|||
public async deletePlatform( |
|||
where: Prisma.PlatformWhereUniqueInput |
|||
): Promise<Platform> { |
|||
return this.prismaService.platform.delete({ where }); |
|||
} |
|||
|
|||
public async getPlatform( |
|||
platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput |
|||
): Promise<Platform> { |
|||
return this.prismaService.platform.findUnique({ |
|||
where: platformWhereUniqueInput |
|||
}); |
|||
} |
|||
|
|||
public async getPlatforms({ |
|||
cursor, |
|||
orderBy, |
|||
skip, |
|||
take, |
|||
where |
|||
}: { |
|||
cursor?: Prisma.PlatformWhereUniqueInput; |
|||
orderBy?: Prisma.PlatformOrderByWithRelationInput; |
|||
skip?: number; |
|||
take?: number; |
|||
where?: Prisma.PlatformWhereInput; |
|||
} = {}) { |
|||
return this.prismaService.platform.findMany({ |
|||
cursor, |
|||
orderBy, |
|||
skip, |
|||
take, |
|||
where |
|||
}); |
|||
} |
|||
|
|||
public async getPlatformsWithAccountCount() { |
|||
const platformsWithAccountCount = |
|||
await this.prismaService.platform.findMany({ |
|||
include: { |
|||
_count: { |
|||
select: { Account: true } |
|||
} |
|||
} |
|||
}); |
|||
|
|||
return platformsWithAccountCount.map(({ _count, id, name, url }) => { |
|||
return { |
|||
id, |
|||
name, |
|||
url, |
|||
accountCount: _count.Account |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public async updatePlatform({ |
|||
data, |
|||
where |
|||
}: { |
|||
data: Prisma.PlatformUpdateInput; |
|||
where: Prisma.PlatformWhereUniqueInput; |
|||
}): Promise<Platform> { |
|||
return this.prismaService.platform.update({ |
|||
data, |
|||
where |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,12 @@ |
|||
import { IsString } from 'class-validator'; |
|||
|
|||
export class UpdatePlatformDto { |
|||
@IsString() |
|||
id: string; |
|||
|
|||
@IsString() |
|||
name: string; |
|||
|
|||
@IsString() |
|||
url: string; |
|||
} |
@ -0,0 +1,9 @@ |
|||
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { GetValueObject } from './get-value-object.interface'; |
|||
|
|||
export interface GetValuesObject { |
|||
dataProviderInfos: DataProviderInfo[]; |
|||
errors: ResponseError['errors']; |
|||
values: GetValueObject[]; |
|||
} |
@ -0,0 +1,132 @@ |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import Big from 'big.js'; |
|||
|
|||
import { CurrentRateServiceMock } from './current-rate.service.mock'; |
|||
import { PortfolioCalculator } from './portfolio-calculator'; |
|||
|
|||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
|||
return { |
|||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|||
CurrentRateService: jest.fn().mockImplementation(() => { |
|||
return CurrentRateServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
describe('PortfolioCalculator', () => { |
|||
let currentRateService: CurrentRateService; |
|||
|
|||
beforeEach(() => { |
|||
currentRateService = new CurrentRateService(null, null, null); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with NOVN.SW buy and sell', async () => { |
|||
const portfolioCalculator = new PortfolioCalculator({ |
|||
currentRateService, |
|||
currency: 'CHF', |
|||
orders: [ |
|||
{ |
|||
currency: 'CHF', |
|||
date: '2022-03-07', |
|||
dataSource: 'YAHOO', |
|||
fee: new Big(0), |
|||
name: 'Novartis AG', |
|||
quantity: new Big(2), |
|||
symbol: 'NOVN.SW', |
|||
type: 'BUY', |
|||
unitPrice: new Big(75.8) |
|||
}, |
|||
{ |
|||
currency: 'CHF', |
|||
date: '2022-04-08', |
|||
dataSource: 'YAHOO', |
|||
fee: new Big(0), |
|||
name: 'Novartis AG', |
|||
quantity: new Big(2), |
|||
symbol: 'NOVN.SW', |
|||
type: 'SELL', |
|||
unitPrice: new Big(85.73) |
|||
} |
|||
] |
|||
}); |
|||
|
|||
portfolioCalculator.computeTransactionPoints(); |
|||
|
|||
const spy = jest |
|||
.spyOn(Date, 'now') |
|||
.mockImplementation(() => parseDate('2022-04-11').getTime()); |
|||
|
|||
const chartData = await portfolioCalculator.getChartData( |
|||
parseDate('2022-03-07') |
|||
); |
|||
|
|||
const currentPositions = await portfolioCalculator.getCurrentPositions( |
|||
parseDate('2022-03-07') |
|||
); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = |
|||
portfolioCalculator.getInvestmentsByGroup('month'); |
|||
|
|||
spy.mockRestore(); |
|||
|
|||
expect(chartData[0]).toEqual({ |
|||
date: '2022-03-07', |
|||
netPerformanceInPercentage: 0, |
|||
netPerformance: 0, |
|||
totalInvestment: 151.6, |
|||
value: 151.6 |
|||
}); |
|||
|
|||
expect(chartData[chartData.length - 1]).toEqual({ |
|||
date: '2022-04-11', |
|||
netPerformanceInPercentage: 13.100263852242744, |
|||
netPerformance: 19.86, |
|||
totalInvestment: 0, |
|||
value: 0 |
|||
}); |
|||
|
|||
expect(currentPositions).toEqual({ |
|||
currentValue: new Big('0'), |
|||
errors: [], |
|||
grossPerformance: new Big('19.86'), |
|||
grossPerformancePercentage: new Big('0.13100263852242744063'), |
|||
hasErrors: false, |
|||
netPerformance: new Big('19.86'), |
|||
netPerformancePercentage: new Big('0.13100263852242744063'), |
|||
positions: [ |
|||
{ |
|||
averagePrice: new Big('0'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
fee: new Big('0'), |
|||
firstBuyDate: '2022-03-07', |
|||
grossPerformance: new Big('19.86'), |
|||
grossPerformancePercentage: new Big('0.13100263852242744063'), |
|||
investment: new Big('0'), |
|||
netPerformance: new Big('19.86'), |
|||
netPerformancePercentage: new Big('0.13100263852242744063'), |
|||
marketPrice: 87.8, |
|||
quantity: new Big('0'), |
|||
symbol: 'NOVN.SW', |
|||
transactionCount: 2 |
|||
} |
|||
], |
|||
totalInvestment: new Big('0') |
|||
}); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2022-03-07', investment: new Big('151.6') }, |
|||
{ date: '2022-04-08', investment: new Big('0') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2022-03-01', investment: new Big('151.6') }, |
|||
{ date: '2022-04-01', investment: new Big('-171.46') } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
File diff suppressed because it is too large
@ -0,0 +1,7 @@ |
|||
import { Cache } from 'cache-manager'; |
|||
|
|||
import type { RedisStore } from './redis-store.interface'; |
|||
|
|||
export interface RedisCache extends Cache { |
|||
store: RedisStore; |
|||
} |
@ -0,0 +1,8 @@ |
|||
import { Store } from 'cache-manager'; |
|||
import { createClient } from 'redis'; |
|||
|
|||
export interface RedisStore extends Store { |
|||
getClient: () => ReturnType<typeof createClient>; |
|||
isCacheableValue: (value: any) => boolean; |
|||
name: 'redis'; |
|||
} |
@ -0,0 +1,36 @@ |
|||
import * as fs from 'fs'; |
|||
import * as path from 'path'; |
|||
|
|||
import { |
|||
DATE_FORMAT, |
|||
getYesterday, |
|||
interpolate |
|||
} from '@ghostfolio/common/helper'; |
|||
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common'; |
|||
import { format } from 'date-fns'; |
|||
import { Response } from 'express'; |
|||
|
|||
@Controller('sitemap.xml') |
|||
export class SitemapController { |
|||
public sitemapXml = ''; |
|||
|
|||
public constructor() { |
|||
try { |
|||
this.sitemapXml = fs.readFileSync( |
|||
path.join(__dirname, 'assets', 'sitemap.xml'), |
|||
'utf8' |
|||
); |
|||
} catch {} |
|||
} |
|||
|
|||
@Get() |
|||
@Version(VERSION_NEUTRAL) |
|||
public async flushCache(@Res() response: Response): Promise<void> { |
|||
response.setHeader('content-type', 'application/xml'); |
|||
response.send( |
|||
interpolate(this.sitemapXml, { |
|||
currentDate: format(getYesterday(), DATE_FORMAT) |
|||
}) |
|||
); |
|||
} |
|||
} |
@ -0,0 +1,24 @@ |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; |
|||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
|||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { SitemapController } from './sitemap.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [SitemapController], |
|||
imports: [ |
|||
ConfigurationModule, |
|||
DataGatheringModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
PrismaModule, |
|||
RedisCacheModule, |
|||
SymbolProfileModule |
|||
] |
|||
}) |
|||
export class SitemapModule {} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue