mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
The merge commit accidentally deleted apps/api/src (NestJS backend),
libs/common/src/lib, libs/ui/src/lib, package.json, nx.json, and all
tsconfig files — causing the API server to fail at build time.
Restored all 667 files from pre-merge commit cdee7514d so the
Ghostfolio API starts cleanly on port 3333 and ghostfolio_reachable=true.
Made-with: Cursor
pull/6453/head
667 changed files with 77037 additions and 0 deletions
@ -0,0 +1,31 @@ |
|||
const baseConfig = require('../../eslint.config.cjs'); |
|||
|
|||
module.exports = [ |
|||
{ |
|||
ignores: ['**/dist'] |
|||
}, |
|||
...baseConfig, |
|||
{ |
|||
rules: {} |
|||
}, |
|||
{ |
|||
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], |
|||
// Override or add rules here |
|||
rules: {}, |
|||
languageOptions: { |
|||
parserOptions: { |
|||
project: ['apps/api/tsconfig.*?.json'] |
|||
} |
|||
} |
|||
}, |
|||
{ |
|||
files: ['**/*.ts', '**/*.tsx'], |
|||
// Override or add rules here |
|||
rules: {} |
|||
}, |
|||
{ |
|||
files: ['**/*.js', '**/*.jsx'], |
|||
// Override or add rules here |
|||
rules: {} |
|||
} |
|||
]; |
|||
@ -0,0 +1,18 @@ |
|||
/* eslint-disable */ |
|||
export default { |
|||
displayName: 'api', |
|||
|
|||
globals: {}, |
|||
transform: { |
|||
'^.+\\.[tj]s$': [ |
|||
'ts-jest', |
|||
{ |
|||
tsconfig: '<rootDir>/tsconfig.spec.json' |
|||
} |
|||
] |
|||
}, |
|||
moduleFileExtensions: ['ts', 'js', 'html'], |
|||
coverageDirectory: '../../coverage/apps/api', |
|||
testEnvironment: 'node', |
|||
preset: '../../jest.preset.js' |
|||
}; |
|||
@ -0,0 +1,78 @@ |
|||
{ |
|||
"name": "api", |
|||
"$schema": "../../node_modules/nx/schemas/project-schema.json", |
|||
"sourceRoot": "apps/api/src", |
|||
"projectType": "application", |
|||
"prefix": "api", |
|||
"generators": {}, |
|||
"targets": { |
|||
"build": { |
|||
"executor": "@nx/webpack:webpack", |
|||
"options": { |
|||
"compiler": "tsc", |
|||
"deleteOutputPath": false, |
|||
"main": "apps/api/src/main.ts", |
|||
"outputPath": "dist/apps/api", |
|||
"sourceMap": true, |
|||
"target": "node", |
|||
"tsConfig": "apps/api/tsconfig.app.json", |
|||
"webpackConfig": "apps/api/webpack.config.js" |
|||
}, |
|||
"configurations": { |
|||
"production": { |
|||
"generatePackageJson": true, |
|||
"optimization": true, |
|||
"extractLicenses": true, |
|||
"inspect": false, |
|||
"fileReplacements": [ |
|||
{ |
|||
"replace": "apps/api/src/environments/environment.ts", |
|||
"with": "apps/api/src/environments/environment.prod.ts" |
|||
} |
|||
] |
|||
} |
|||
}, |
|||
"outputs": ["{options.outputPath}"] |
|||
}, |
|||
"copy-assets": { |
|||
"executor": "nx:run-commands", |
|||
"options": { |
|||
"commands": [ |
|||
{ |
|||
"command": "shx rm -rf dist/apps/api" |
|||
}, |
|||
{ |
|||
"command": "shx mkdir -p dist/apps/api/assets/locales" |
|||
}, |
|||
{ |
|||
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets" |
|||
}, |
|||
{ |
|||
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales" |
|||
} |
|||
], |
|||
"parallel": false |
|||
} |
|||
}, |
|||
"serve": { |
|||
"executor": "@nx/js:node", |
|||
"options": { |
|||
"buildTarget": "api:build" |
|||
} |
|||
}, |
|||
"lint": { |
|||
"executor": "@nx/eslint:lint", |
|||
"options": { |
|||
"lintFilePatterns": ["apps/api/**/*.ts"] |
|||
} |
|||
}, |
|||
"test": { |
|||
"executor": "@nx/jest:jest", |
|||
"options": { |
|||
"jestConfig": "apps/api/jest.config.ts" |
|||
}, |
|||
"outputs": ["{workspaceRoot}/coverage/apps/api"] |
|||
} |
|||
}, |
|||
"tags": [] |
|||
} |
|||
@ -0,0 +1,171 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; |
|||
import { Access } from '@ghostfolio/common/interfaces'; |
|||
import { 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 { Access as AccessModel } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { AccessService } from './access.service'; |
|||
|
|||
@Controller('access') |
|||
export class AccessController { |
|||
public constructor( |
|||
private readonly accessService: AccessService, |
|||
private readonly configurationService: ConfigurationService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getAllAccesses(): Promise<Access[]> { |
|||
const accessesWithGranteeUser = await this.accessService.accesses({ |
|||
include: { |
|||
granteeUser: true |
|||
}, |
|||
orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }], |
|||
where: { userId: this.request.user.id } |
|||
}); |
|||
|
|||
return accessesWithGranteeUser.map( |
|||
({ alias, granteeUser, id, permissions }) => { |
|||
if (granteeUser) { |
|||
return { |
|||
alias, |
|||
id, |
|||
permissions, |
|||
grantee: granteeUser?.id, |
|||
type: 'PRIVATE' |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
alias, |
|||
id, |
|||
permissions, |
|||
grantee: 'Public', |
|||
type: 'PUBLIC' |
|||
}; |
|||
} |
|||
); |
|||
} |
|||
|
|||
@HasPermission(permissions.createAccess) |
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async createAccess( |
|||
@Body() data: CreateAccessDto |
|||
): Promise<AccessModel> { |
|||
if ( |
|||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && |
|||
this.request.user.subscription.type === 'Basic' |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
try { |
|||
return this.accessService.createAccess({ |
|||
alias: data.alias || undefined, |
|||
granteeUser: data.granteeUserId |
|||
? { connect: { id: data.granteeUserId } } |
|||
: undefined, |
|||
permissions: data.permissions, |
|||
user: { connect: { id: this.request.user.id } } |
|||
}); |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Delete(':id') |
|||
@HasPermission(permissions.deleteAccess) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> { |
|||
const originalAccess = await this.accessService.access({ |
|||
id, |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
if (!originalAccess) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.accessService.deleteAccess({ |
|||
id |
|||
}); |
|||
} |
|||
|
|||
@HasPermission(permissions.updateAccess) |
|||
@Put(':id') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async updateAccess( |
|||
@Body() data: UpdateAccessDto, |
|||
@Param('id') id: string |
|||
): Promise<AccessModel> { |
|||
if ( |
|||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && |
|||
this.request.user.subscription.type === 'Basic' |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
const originalAccess = await this.accessService.access({ |
|||
id, |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
if (!originalAccess) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
try { |
|||
return this.accessService.updateAccess({ |
|||
data: { |
|||
alias: data.alias, |
|||
granteeUser: data.granteeUserId |
|||
? { connect: { id: data.granteeUserId } } |
|||
: { disconnect: true }, |
|||
permissions: data.permissions |
|||
}, |
|||
where: { id } |
|||
}); |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AccessController } from './access.controller'; |
|||
import { AccessService } from './access.service'; |
|||
|
|||
@Module({ |
|||
controllers: [AccessController], |
|||
exports: [AccessService], |
|||
imports: [ConfigurationModule, PrismaModule], |
|||
providers: [AccessService] |
|||
}) |
|||
export class AccessModule {} |
|||
@ -0,0 +1,68 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { AccessWithGranteeUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Access, Prisma } from '@prisma/client'; |
|||
|
|||
@Injectable() |
|||
export class AccessService { |
|||
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: { |
|||
cursor?: Prisma.AccessWhereUniqueInput; |
|||
include?: Prisma.AccessInclude; |
|||
orderBy?: Prisma.Enumerable<Prisma.AccessOrderByWithRelationInput>; |
|||
skip?: number; |
|||
take?: number; |
|||
where?: Prisma.AccessWhereInput; |
|||
}): Promise<AccessWithGranteeUser[]> { |
|||
const { cursor, include, orderBy, skip, take, where } = params; |
|||
|
|||
return this.prismaService.access.findMany({ |
|||
cursor, |
|||
include, |
|||
orderBy, |
|||
skip, |
|||
take, |
|||
where |
|||
}); |
|||
} |
|||
|
|||
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> { |
|||
return this.prismaService.access.create({ |
|||
data |
|||
}); |
|||
} |
|||
|
|||
public async deleteAccess( |
|||
where: Prisma.AccessWhereUniqueInput |
|||
): Promise<Access> { |
|||
return this.prismaService.access.delete({ |
|||
where |
|||
}); |
|||
} |
|||
|
|||
public async updateAccess({ |
|||
data, |
|||
where |
|||
}: { |
|||
data: Prisma.AccessUpdateInput; |
|||
where: Prisma.AccessWhereUniqueInput; |
|||
}): Promise<Access> { |
|||
return this.prismaService.access.update({ |
|||
data, |
|||
where |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,84 @@ |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Controller, |
|||
Body, |
|||
Post, |
|||
Delete, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { AccountBalance } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { AccountBalanceService } from './account-balance.service'; |
|||
|
|||
@Controller('account-balance') |
|||
export class AccountBalanceController { |
|||
public constructor( |
|||
private readonly accountBalanceService: AccountBalanceService, |
|||
private readonly accountService: AccountService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@HasPermission(permissions.createAccountBalance) |
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async createAccountBalance( |
|||
@Body() data: CreateAccountBalanceDto |
|||
): Promise<AccountBalance> { |
|||
const account = await this.accountService.account({ |
|||
id_userId: { |
|||
id: data.accountId, |
|||
userId: this.request.user.id |
|||
} |
|||
}); |
|||
|
|||
if (!account) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.accountBalanceService.createOrUpdateAccountBalance({ |
|||
accountId: account.id, |
|||
balance: data.balance, |
|||
date: data.date, |
|||
userId: account.userId |
|||
}); |
|||
} |
|||
|
|||
@HasPermission(permissions.deleteAccountBalance) |
|||
@Delete(':id') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteAccountBalance( |
|||
@Param('id') id: string |
|||
): Promise<AccountBalance> { |
|||
const accountBalance = await this.accountBalanceService.accountBalance({ |
|||
id, |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
if (!accountBalance) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.accountBalanceService.deleteAccountBalance({ |
|||
id: accountBalance.id, |
|||
userId: accountBalance.userId |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AccountBalanceController } from './account-balance.controller'; |
|||
import { AccountBalanceService } from './account-balance.service'; |
|||
|
|||
@Module({ |
|||
controllers: [AccountBalanceController], |
|||
exports: [AccountBalanceService], |
|||
imports: [ExchangeRateDataModule, PrismaModule], |
|||
providers: [AccountBalanceService, AccountService] |
|||
}) |
|||
export class AccountBalanceModule {} |
|||
@ -0,0 +1,186 @@ |
|||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; |
|||
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; |
|||
import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper'; |
|||
import { |
|||
AccountBalancesResponse, |
|||
Filter, |
|||
HistoricalDataItem |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { EventEmitter2 } from '@nestjs/event-emitter'; |
|||
import { AccountBalance, Prisma } from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
import { format, parseISO } from 'date-fns'; |
|||
|
|||
@Injectable() |
|||
export class AccountBalanceService { |
|||
public constructor( |
|||
private readonly eventEmitter: EventEmitter2, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly prismaService: PrismaService |
|||
) {} |
|||
|
|||
public async accountBalance( |
|||
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput |
|||
): Promise<AccountBalance | null> { |
|||
return this.prismaService.accountBalance.findFirst({ |
|||
include: { |
|||
account: true |
|||
}, |
|||
where: accountBalanceWhereInput |
|||
}); |
|||
} |
|||
|
|||
public async createOrUpdateAccountBalance({ |
|||
accountId, |
|||
balance, |
|||
date, |
|||
userId |
|||
}: CreateAccountBalanceDto & { |
|||
userId: string; |
|||
}): Promise<AccountBalance> { |
|||
const accountBalance = await this.prismaService.accountBalance.upsert({ |
|||
create: { |
|||
account: { |
|||
connect: { |
|||
id_userId: { |
|||
userId, |
|||
id: accountId |
|||
} |
|||
} |
|||
}, |
|||
date: resetHours(parseISO(date)), |
|||
value: balance |
|||
}, |
|||
update: { |
|||
value: balance |
|||
}, |
|||
where: { |
|||
accountId_date: { |
|||
accountId, |
|||
date: resetHours(parseISO(date)) |
|||
} |
|||
} |
|||
}); |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ |
|||
userId |
|||
}) |
|||
); |
|||
|
|||
return accountBalance; |
|||
} |
|||
|
|||
public async deleteAccountBalance( |
|||
where: Prisma.AccountBalanceWhereUniqueInput |
|||
): Promise<AccountBalance> { |
|||
const accountBalance = await this.prismaService.accountBalance.delete({ |
|||
where |
|||
}); |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ |
|||
userId: where.userId as string |
|||
}) |
|||
); |
|||
|
|||
return accountBalance; |
|||
} |
|||
|
|||
public async getAccountBalanceItems({ |
|||
filters, |
|||
userCurrency, |
|||
userId |
|||
}: { |
|||
filters?: Filter[]; |
|||
userCurrency: string; |
|||
userId: string; |
|||
}): Promise<HistoricalDataItem[]> { |
|||
const { balances } = await this.getAccountBalances({ |
|||
filters, |
|||
userCurrency, |
|||
userId, |
|||
withExcludedAccounts: false // TODO
|
|||
}); |
|||
const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = |
|||
{}; |
|||
const lastBalancesByAccount: { [accountId: string]: Big } = {}; |
|||
|
|||
for (const { accountId, date, valueInBaseCurrency } of balances) { |
|||
const formattedDate = format(date, DATE_FORMAT); |
|||
|
|||
lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); |
|||
|
|||
const totalBalance = getSum(Object.values(lastBalancesByAccount)); |
|||
|
|||
// Add or update the accumulated balance for this date
|
|||
accumulatedBalancesByDate[formattedDate] = { |
|||
date: formattedDate, |
|||
value: totalBalance.toNumber() |
|||
}; |
|||
} |
|||
|
|||
return Object.values(accumulatedBalancesByDate); |
|||
} |
|||
|
|||
@LogPerformance |
|||
public async getAccountBalances({ |
|||
filters, |
|||
userCurrency, |
|||
userId, |
|||
withExcludedAccounts |
|||
}: { |
|||
filters?: Filter[]; |
|||
userCurrency: string; |
|||
userId: string; |
|||
withExcludedAccounts?: boolean; |
|||
}): Promise<AccountBalancesResponse> { |
|||
const where: Prisma.AccountBalanceWhereInput = { userId }; |
|||
|
|||
const accountFilter = filters?.find(({ type }) => { |
|||
return type === 'ACCOUNT'; |
|||
}); |
|||
|
|||
if (accountFilter) { |
|||
where.accountId = accountFilter.id; |
|||
} |
|||
|
|||
if (withExcludedAccounts === false) { |
|||
where.account = { isExcluded: false }; |
|||
} |
|||
|
|||
const balances = await this.prismaService.accountBalance.findMany({ |
|||
where, |
|||
orderBy: { |
|||
date: 'asc' |
|||
}, |
|||
select: { |
|||
account: true, |
|||
date: true, |
|||
id: true, |
|||
value: true |
|||
} |
|||
}); |
|||
|
|||
return { |
|||
balances: balances.map((balance) => { |
|||
return { |
|||
...balance, |
|||
accountId: balance.account.id, |
|||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( |
|||
balance.value, |
|||
balance.account.currency, |
|||
userCurrency |
|||
) |
|||
}; |
|||
}) |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,295 @@ |
|||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
|||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
|||
import { |
|||
CreateAccountDto, |
|||
TransferBalanceDto, |
|||
UpdateAccountDto |
|||
} from '@ghostfolio/common/dtos'; |
|||
import { |
|||
AccountBalancesResponse, |
|||
AccountResponse, |
|||
AccountsResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
Headers, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
Put, |
|||
Query, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { Account as AccountModel } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { AccountService } from './account.service'; |
|||
|
|||
@Controller('account') |
|||
export class AccountController { |
|||
public constructor( |
|||
private readonly accountBalanceService: AccountBalanceService, |
|||
private readonly accountService: AccountService, |
|||
private readonly apiService: ApiService, |
|||
private readonly impersonationService: ImpersonationService, |
|||
private readonly portfolioService: PortfolioService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Delete(':id') |
|||
@HasPermission(permissions.deleteAccount) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> { |
|||
const account = await this.accountService.accountWithActivities( |
|||
{ |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
}, |
|||
{ activities: true } |
|||
); |
|||
|
|||
if (!account || account?.activities.length > 0) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.accountService.deleteAccount({ |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
}); |
|||
} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(RedactValuesInResponseInterceptor) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async getAllAccounts( |
|||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('query') filterBySearchQuery?: string, |
|||
@Query('symbol') filterBySymbol?: string |
|||
): Promise<AccountsResponse> { |
|||
const impersonationUserId = |
|||
await this.impersonationService.validateImpersonationId(impersonationId); |
|||
|
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByDataSource, |
|||
filterBySearchQuery, |
|||
filterBySymbol |
|||
}); |
|||
|
|||
return this.portfolioService.getAccountsWithAggregations({ |
|||
filters, |
|||
userId: impersonationUserId || this.request.user.id, |
|||
withExcludedAccounts: true |
|||
}); |
|||
} |
|||
|
|||
@Get(':id') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(RedactValuesInResponseInterceptor) |
|||
public async getAccountById( |
|||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, |
|||
@Param('id') id: string |
|||
): Promise<AccountResponse> { |
|||
const impersonationUserId = |
|||
await this.impersonationService.validateImpersonationId(impersonationId); |
|||
|
|||
const accountsWithAggregations = |
|||
await this.portfolioService.getAccountsWithAggregations({ |
|||
filters: [{ id, type: 'ACCOUNT' }], |
|||
userId: impersonationUserId || this.request.user.id, |
|||
withExcludedAccounts: true |
|||
}); |
|||
|
|||
return accountsWithAggregations.accounts[0]; |
|||
} |
|||
|
|||
@Get(':id/balances') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(RedactValuesInResponseInterceptor) |
|||
public async getAccountBalancesById( |
|||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, |
|||
@Param('id') id: string |
|||
): Promise<AccountBalancesResponse> { |
|||
const impersonationUserId = |
|||
await this.impersonationService.validateImpersonationId(impersonationId); |
|||
|
|||
return this.accountBalanceService.getAccountBalances({ |
|||
filters: [{ id, type: 'ACCOUNT' }], |
|||
userCurrency: this.request.user.settings.settings.baseCurrency, |
|||
userId: impersonationUserId || this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@HasPermission(permissions.createAccount) |
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async createAccount( |
|||
@Body() data: CreateAccountDto |
|||
): Promise<AccountModel> { |
|||
if (data.platformId) { |
|||
const platformId = data.platformId; |
|||
delete data.platformId; |
|||
|
|||
return this.accountService.createAccount( |
|||
{ |
|||
...data, |
|||
platform: { connect: { id: platformId } }, |
|||
user: { connect: { id: this.request.user.id } } |
|||
}, |
|||
this.request.user.id |
|||
); |
|||
} else { |
|||
delete data.platformId; |
|||
|
|||
return this.accountService.createAccount( |
|||
{ |
|||
...data, |
|||
user: { connect: { id: this.request.user.id } } |
|||
}, |
|||
this.request.user.id |
|||
); |
|||
} |
|||
} |
|||
|
|||
@HasPermission(permissions.updateAccount) |
|||
@Post('transfer-balance') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async transferAccountBalance( |
|||
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto |
|||
) { |
|||
const accountsOfUser = await this.accountService.getAccounts( |
|||
this.request.user.id |
|||
); |
|||
|
|||
const accountFrom = accountsOfUser.find(({ id }) => { |
|||
return id === accountIdFrom; |
|||
}); |
|||
|
|||
const accountTo = accountsOfUser.find(({ id }) => { |
|||
return id === accountIdTo; |
|||
}); |
|||
|
|||
if (!accountFrom || !accountTo) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
if (accountFrom.id === accountTo.id) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
|
|||
if (accountFrom.balance < balance) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
|
|||
await this.accountService.updateAccountBalance({ |
|||
accountId: accountFrom.id, |
|||
amount: -balance, |
|||
currency: accountFrom.currency, |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
await this.accountService.updateAccountBalance({ |
|||
accountId: accountTo.id, |
|||
amount: balance, |
|||
currency: accountFrom.currency, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@HasPermission(permissions.updateAccount) |
|||
@Put(':id') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { |
|||
const originalAccount = await this.accountService.account({ |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
}); |
|||
|
|||
if (!originalAccount) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
if (data.platformId) { |
|||
const platformId = data.platformId; |
|||
delete data.platformId; |
|||
|
|||
return this.accountService.updateAccount( |
|||
{ |
|||
data: { |
|||
...data, |
|||
platform: { connect: { id: platformId } }, |
|||
user: { connect: { id: this.request.user.id } } |
|||
}, |
|||
where: { |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
} |
|||
}, |
|||
this.request.user.id |
|||
); |
|||
} else { |
|||
// platformId is null, remove it
|
|||
delete data.platformId; |
|||
|
|||
return this.accountService.updateAccount( |
|||
{ |
|||
data: { |
|||
...data, |
|||
platform: originalAccount.platformId |
|||
? { disconnect: true } |
|||
: undefined, |
|||
user: { connect: { id: this.request.user.id } } |
|||
}, |
|||
where: { |
|||
id_userId: { |
|||
id, |
|||
userId: this.request.user.id |
|||
} |
|||
} |
|||
}, |
|||
this.request.user.id |
|||
); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; |
|||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; |
|||
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
|||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AccountController } from './account.controller'; |
|||
import { AccountService } from './account.service'; |
|||
|
|||
@Module({ |
|||
controllers: [AccountController], |
|||
exports: [AccountService], |
|||
imports: [ |
|||
AccountBalanceModule, |
|||
ApiModule, |
|||
ConfigurationModule, |
|||
ExchangeRateDataModule, |
|||
ImpersonationModule, |
|||
PortfolioModule, |
|||
PrismaModule, |
|||
RedactValuesInResponseModule |
|||
], |
|||
providers: [AccountService] |
|||
}) |
|||
export class AccountModule {} |
|||
@ -0,0 +1,288 @@ |
|||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
|||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { DATE_FORMAT } from '@ghostfolio/common/helper'; |
|||
import { Filter } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { EventEmitter2 } from '@nestjs/event-emitter'; |
|||
import { |
|||
Account, |
|||
AccountBalance, |
|||
Order, |
|||
Platform, |
|||
Prisma, |
|||
SymbolProfile |
|||
} from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
import { format } from 'date-fns'; |
|||
import { groupBy } from 'lodash'; |
|||
|
|||
import { CashDetails } from './interfaces/cash-details.interface'; |
|||
|
|||
@Injectable() |
|||
export class AccountService { |
|||
public constructor( |
|||
private readonly accountBalanceService: AccountBalanceService, |
|||
private readonly eventEmitter: EventEmitter2, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly prismaService: PrismaService |
|||
) {} |
|||
|
|||
public async account({ |
|||
id_userId |
|||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { |
|||
const [account] = await this.accounts({ |
|||
where: id_userId |
|||
}); |
|||
|
|||
return account; |
|||
} |
|||
|
|||
public async accountWithActivities( |
|||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput, |
|||
accountInclude: Prisma.AccountInclude |
|||
): Promise< |
|||
Account & { |
|||
activities?: Order[]; |
|||
} |
|||
> { |
|||
return this.prismaService.account.findUnique({ |
|||
include: accountInclude, |
|||
where: accountWhereUniqueInput |
|||
}); |
|||
} |
|||
|
|||
public async accounts(params: { |
|||
include?: Prisma.AccountInclude; |
|||
skip?: number; |
|||
take?: number; |
|||
cursor?: Prisma.AccountWhereUniqueInput; |
|||
where?: Prisma.AccountWhereInput; |
|||
orderBy?: Prisma.AccountOrderByWithRelationInput; |
|||
}): Promise< |
|||
(Account & { |
|||
activities?: (Order & { SymbolProfile?: SymbolProfile })[]; |
|||
balances?: AccountBalance[]; |
|||
platform?: Platform; |
|||
})[] |
|||
> { |
|||
const { include = {}, skip, take, cursor, where, orderBy } = params; |
|||
|
|||
const isBalancesIncluded = !!include.balances; |
|||
|
|||
include.balances = { |
|||
orderBy: { date: 'desc' }, |
|||
...(isBalancesIncluded ? {} : { take: 1 }) |
|||
}; |
|||
|
|||
const accounts = await this.prismaService.account.findMany({ |
|||
cursor, |
|||
include, |
|||
orderBy, |
|||
skip, |
|||
take, |
|||
where |
|||
}); |
|||
|
|||
return accounts.map((account) => { |
|||
account = { ...account, balance: account.balances[0]?.value ?? 0 }; |
|||
|
|||
if (!isBalancesIncluded) { |
|||
delete account.balances; |
|||
} |
|||
|
|||
return account; |
|||
}); |
|||
} |
|||
|
|||
public async createAccount( |
|||
data: Prisma.AccountCreateInput, |
|||
aUserId: string |
|||
): Promise<Account> { |
|||
const account = await this.prismaService.account.create({ |
|||
data |
|||
}); |
|||
|
|||
await this.accountBalanceService.createOrUpdateAccountBalance({ |
|||
accountId: account.id, |
|||
balance: data.balance, |
|||
date: format(new Date(), DATE_FORMAT), |
|||
userId: aUserId |
|||
}); |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ |
|||
userId: account.userId |
|||
}) |
|||
); |
|||
|
|||
return account; |
|||
} |
|||
|
|||
public async deleteAccount( |
|||
where: Prisma.AccountWhereUniqueInput |
|||
): Promise<Account> { |
|||
const account = await this.prismaService.account.delete({ |
|||
where |
|||
}); |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ |
|||
userId: account.userId |
|||
}) |
|||
); |
|||
|
|||
return account; |
|||
} |
|||
|
|||
public async getAccounts(aUserId: string): Promise<Account[]> { |
|||
const accounts = await this.accounts({ |
|||
include: { |
|||
activities: true, |
|||
platform: true |
|||
}, |
|||
orderBy: { name: 'asc' }, |
|||
where: { userId: aUserId } |
|||
}); |
|||
|
|||
return accounts.map((account) => { |
|||
let activitiesCount = 0; |
|||
|
|||
for (const { isDraft } of account.activities) { |
|||
if (!isDraft) { |
|||
activitiesCount += 1; |
|||
} |
|||
} |
|||
|
|||
const result = { ...account, activitiesCount }; |
|||
|
|||
delete result.activities; |
|||
|
|||
return result; |
|||
}); |
|||
} |
|||
|
|||
public async getCashDetails({ |
|||
currency, |
|||
filters = [], |
|||
userId, |
|||
withExcludedAccounts = false |
|||
}: { |
|||
currency: string; |
|||
filters?: Filter[]; |
|||
userId: string; |
|||
withExcludedAccounts?: boolean; |
|||
}): Promise<CashDetails> { |
|||
let totalCashBalanceInBaseCurrency = new Big(0); |
|||
|
|||
const where: Prisma.AccountWhereInput = { |
|||
userId |
|||
}; |
|||
|
|||
if (withExcludedAccounts === false) { |
|||
where.isExcluded = false; |
|||
} |
|||
|
|||
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => { |
|||
return type; |
|||
}); |
|||
|
|||
if (filtersByAccount?.length > 0) { |
|||
where.id = { |
|||
in: filtersByAccount.map(({ id }) => { |
|||
return id; |
|||
}) |
|||
}; |
|||
} |
|||
|
|||
const accounts = await this.accounts({ where }); |
|||
|
|||
for (const account of accounts) { |
|||
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus( |
|||
this.exchangeRateDataService.toCurrency( |
|||
account.balance, |
|||
account.currency, |
|||
currency |
|||
) |
|||
); |
|||
} |
|||
|
|||
return { |
|||
accounts, |
|||
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber() |
|||
}; |
|||
} |
|||
|
|||
public async updateAccount( |
|||
params: { |
|||
where: Prisma.AccountWhereUniqueInput; |
|||
data: Prisma.AccountUpdateInput; |
|||
}, |
|||
aUserId: string |
|||
): Promise<Account> { |
|||
const { data, where } = params; |
|||
|
|||
await this.accountBalanceService.createOrUpdateAccountBalance({ |
|||
accountId: data.id as string, |
|||
balance: data.balance as number, |
|||
date: format(new Date(), DATE_FORMAT), |
|||
userId: aUserId |
|||
}); |
|||
|
|||
const account = await this.prismaService.account.update({ |
|||
data, |
|||
where |
|||
}); |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ |
|||
userId: account.userId |
|||
}) |
|||
); |
|||
|
|||
return account; |
|||
} |
|||
|
|||
public async updateAccountBalance({ |
|||
accountId, |
|||
amount, |
|||
currency, |
|||
date = new Date(), |
|||
userId |
|||
}: { |
|||
accountId: string; |
|||
amount: number; |
|||
currency: string; |
|||
date?: Date; |
|||
userId: string; |
|||
}) { |
|||
const { balance, currency: currencyOfAccount } = await this.account({ |
|||
id_userId: { |
|||
userId, |
|||
id: accountId |
|||
} |
|||
}); |
|||
|
|||
const amountInCurrencyOfAccount = |
|||
await this.exchangeRateDataService.toCurrencyAtDate( |
|||
amount, |
|||
currency, |
|||
currencyOfAccount, |
|||
date |
|||
); |
|||
|
|||
if (amountInCurrencyOfAccount) { |
|||
await this.accountBalanceService.createOrUpdateAccountBalance({ |
|||
accountId, |
|||
userId, |
|||
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(), |
|||
date: date.toISOString() |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,6 @@ |
|||
import { Account } from '@prisma/client'; |
|||
|
|||
export interface CashDetails { |
|||
accounts: Account[]; |
|||
balanceInBaseCurrency: number; |
|||
} |
|||
@ -0,0 +1,337 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; |
|||
import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
|||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; |
|||
import { |
|||
DATA_GATHERING_QUEUE_PRIORITY_HIGH, |
|||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, |
|||
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, |
|||
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS |
|||
} from '@ghostfolio/common/config'; |
|||
import { |
|||
UpdateAssetProfileDto, |
|||
UpdatePropertyDto |
|||
} from '@ghostfolio/common/dtos'; |
|||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; |
|||
import { |
|||
AdminData, |
|||
AdminMarketData, |
|||
AdminUserResponse, |
|||
AdminUsersResponse, |
|||
EnhancedSymbolProfile, |
|||
ScraperConfiguration |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { |
|||
DateRange, |
|||
MarketDataPreset, |
|||
RequestWithUser |
|||
} from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Logger, |
|||
Param, |
|||
Patch, |
|||
Post, |
|||
Put, |
|||
Query, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; |
|||
import { isDate, parseISO } from 'date-fns'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { AdminService } from './admin.service'; |
|||
|
|||
@Controller('admin') |
|||
export class AdminController { |
|||
public constructor( |
|||
private readonly adminService: AdminService, |
|||
private readonly apiService: ApiService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
private readonly demoService: DemoService, |
|||
private readonly manualService: ManualService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getAdminData(): Promise<AdminData> { |
|||
return this.adminService.get(); |
|||
} |
|||
|
|||
@Get('demo-user/sync') |
|||
@HasPermission(permissions.syncDemoUserAccount) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async syncDemoUserAccount(): Promise<Prisma.BatchPayload> { |
|||
return this.demoService.syncDemoUserAccount(); |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Post('gather') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async gather7Days(): Promise<void> { |
|||
this.dataGatheringService.gather7Days(); |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Post('gather/max') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async gatherMax(): Promise<void> { |
|||
const assetProfileIdentifiers = |
|||
await this.dataGatheringService.getActiveAssetProfileIdentifiers(); |
|||
|
|||
await this.dataGatheringService.addJobsToQueue( |
|||
assetProfileIdentifiers.map(({ dataSource, symbol }) => { |
|||
return { |
|||
data: { |
|||
dataSource, |
|||
symbol |
|||
}, |
|||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, |
|||
opts: { |
|||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, |
|||
jobId: getAssetProfileIdentifier({ dataSource, symbol }), |
|||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM |
|||
} |
|||
}; |
|||
}) |
|||
); |
|||
|
|||
this.dataGatheringService.gatherMax(); |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Post('gather/profile-data') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async gatherProfileData(): Promise<void> { |
|||
const assetProfileIdentifiers = |
|||
await this.dataGatheringService.getActiveAssetProfileIdentifiers(); |
|||
|
|||
await this.dataGatheringService.addJobsToQueue( |
|||
assetProfileIdentifiers.map(({ dataSource, symbol }) => { |
|||
return { |
|||
data: { |
|||
dataSource, |
|||
symbol |
|||
}, |
|||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, |
|||
opts: { |
|||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, |
|||
jobId: getAssetProfileIdentifier({ dataSource, symbol }), |
|||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM |
|||
} |
|||
}; |
|||
}) |
|||
); |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Post('gather/profile-data/:dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async gatherProfileDataForSymbol( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<void> { |
|||
await this.dataGatheringService.addJobToQueue({ |
|||
data: { |
|||
dataSource, |
|||
symbol |
|||
}, |
|||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, |
|||
opts: { |
|||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, |
|||
jobId: getAssetProfileIdentifier({ dataSource, symbol }), |
|||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH |
|||
} |
|||
}); |
|||
} |
|||
|
|||
@Post('gather/:dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@HasPermission(permissions.accessAdminControl) |
|||
public async gatherSymbol( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string, |
|||
@Query('range') dateRange: DateRange |
|||
): Promise<void> { |
|||
let date: Date; |
|||
|
|||
if (dateRange) { |
|||
const { startDate } = getIntervalFromDateRange(dateRange); |
|||
date = startDate; |
|||
} |
|||
|
|||
this.dataGatheringService.gatherSymbol({ |
|||
dataSource, |
|||
date, |
|||
symbol |
|||
}); |
|||
|
|||
return; |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Post('gather/:dataSource/:symbol/:dateString') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async gatherSymbolForDate( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('dateString') dateString: string, |
|||
@Param('symbol') symbol: string |
|||
): Promise<MarketData> { |
|||
const date = parseISO(dateString); |
|||
|
|||
if (!isDate(date)) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
|
|||
return this.dataGatheringService.gatherSymbolForDate({ |
|||
dataSource, |
|||
date, |
|||
symbol |
|||
}); |
|||
} |
|||
|
|||
@Get('market-data') |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getMarketData( |
|||
@Query('assetSubClasses') filterByAssetSubClasses?: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('presetId') presetId?: MarketDataPreset, |
|||
@Query('query') filterBySearchQuery?: string, |
|||
@Query('skip') skip?: number, |
|||
@Query('sortColumn') sortColumn?: string, |
|||
@Query('sortDirection') sortDirection?: Prisma.SortOrder, |
|||
@Query('take') take?: number |
|||
): Promise<AdminMarketData> { |
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAssetSubClasses, |
|||
filterByDataSource, |
|||
filterBySearchQuery |
|||
}); |
|||
|
|||
return this.adminService.getMarketData({ |
|||
filters, |
|||
presetId, |
|||
sortColumn, |
|||
sortDirection, |
|||
skip: isNaN(skip) ? undefined : skip, |
|||
take: isNaN(take) ? undefined : take |
|||
}); |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Post('market-data/:dataSource/:symbol/test') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async testMarketData( |
|||
@Body() data: { scraperConfiguration: ScraperConfiguration }, |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<{ price: number }> { |
|||
try { |
|||
const price = await this.manualService.test({ |
|||
symbol, |
|||
scraperConfiguration: data.scraperConfiguration |
|||
}); |
|||
|
|||
if (price) { |
|||
return { price }; |
|||
} |
|||
|
|||
throw new Error( |
|||
`Could not parse the market price for ${symbol} (${dataSource})` |
|||
); |
|||
} catch (error) { |
|||
Logger.error(error, 'AdminController'); |
|||
|
|||
throw new HttpException(error.message, StatusCodes.BAD_REQUEST); |
|||
} |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Post('profile-data/:dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async addProfileData( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<SymbolProfile | never> { |
|||
return this.adminService.addAssetProfile({ |
|||
dataSource, |
|||
symbol, |
|||
currency: this.request.user.settings.settings.baseCurrency |
|||
}); |
|||
} |
|||
|
|||
@Delete('profile-data/:dataSource/:symbol') |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteProfileData( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<void> { |
|||
return this.adminService.deleteProfileData({ dataSource, symbol }); |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Patch('profile-data/:dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async patchAssetProfileData( |
|||
@Body() assetProfile: UpdateAssetProfileDto, |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<EnhancedSymbolProfile> { |
|||
return this.adminService.patchAssetProfileData( |
|||
{ dataSource, symbol }, |
|||
assetProfile |
|||
); |
|||
} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Put('settings/:key') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async updateProperty( |
|||
@Param('key') key: string, |
|||
@Body() data: UpdatePropertyDto |
|||
) { |
|||
return this.adminService.putSetting(key, data.value); |
|||
} |
|||
|
|||
@Get('user') |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getUsers( |
|||
@Query('skip') skip?: number, |
|||
@Query('take') take?: number |
|||
): Promise<AdminUsersResponse> { |
|||
return this.adminService.getUsers({ |
|||
skip: isNaN(skip) ? undefined : skip, |
|||
take: isNaN(take) ? undefined : take |
|||
}); |
|||
} |
|||
|
|||
@Get('user/:id') |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getUser(@Param('id') id: string): Promise<AdminUserResponse> { |
|||
return this.adminService.getUser(id); |
|||
} |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
|||
import { DemoModule } from '@ghostfolio/api/services/demo/demo.module'; |
|||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AdminController } from './admin.controller'; |
|||
import { AdminService } from './admin.service'; |
|||
import { QueueModule } from './queue/queue.module'; |
|||
|
|||
@Module({ |
|||
imports: [ |
|||
ApiModule, |
|||
BenchmarkModule, |
|||
ConfigurationModule, |
|||
DataGatheringModule, |
|||
DataProviderModule, |
|||
DemoModule, |
|||
ExchangeRateDataModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
PrismaModule, |
|||
PropertyModule, |
|||
QueueModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInRequestModule |
|||
], |
|||
controllers: [AdminController], |
|||
providers: [AdminService], |
|||
exports: [AdminService] |
|||
}) |
|||
export class AdminModule {} |
|||
@ -0,0 +1,938 @@ |
|||
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
|||
import { environment } from '@ghostfolio/api/environments/environment'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { |
|||
PROPERTY_CURRENCIES, |
|||
PROPERTY_IS_READ_ONLY_MODE, |
|||
PROPERTY_IS_USER_SIGNUP_ENABLED |
|||
} from '@ghostfolio/common/config'; |
|||
import { |
|||
getAssetProfileIdentifier, |
|||
getCurrencyFromSymbol, |
|||
isCurrency |
|||
} from '@ghostfolio/common/helper'; |
|||
import { |
|||
AdminData, |
|||
AdminMarketData, |
|||
AdminMarketDataDetails, |
|||
AdminMarketDataItem, |
|||
AdminUserResponse, |
|||
AdminUsersResponse, |
|||
AssetProfileIdentifier, |
|||
EnhancedSymbolProfile, |
|||
Filter |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; |
|||
import { MarketDataPreset } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
BadRequestException, |
|||
HttpException, |
|||
Injectable, |
|||
Logger, |
|||
NotFoundException |
|||
} from '@nestjs/common'; |
|||
import { |
|||
AssetClass, |
|||
AssetSubClass, |
|||
DataSource, |
|||
Prisma, |
|||
PrismaClient, |
|||
Property, |
|||
SymbolProfile |
|||
} from '@prisma/client'; |
|||
import { differenceInDays } from 'date-fns'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
import { groupBy } from 'lodash'; |
|||
|
|||
@Injectable() |
|||
export class AdminService { |
|||
public constructor( |
|||
private readonly benchmarkService: BenchmarkService, |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly marketDataService: MarketDataService, |
|||
private readonly orderService: OrderService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly propertyService: PropertyService, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
public async addAssetProfile({ |
|||
currency, |
|||
dataSource, |
|||
symbol |
|||
}: AssetProfileIdentifier & { currency?: string }): Promise< |
|||
SymbolProfile | never |
|||
> { |
|||
try { |
|||
if (dataSource === 'MANUAL') { |
|||
return this.symbolProfileService.add({ |
|||
currency, |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
} |
|||
|
|||
const assetProfiles = await this.dataProviderService.getAssetProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfiles[symbol]?.currency) { |
|||
throw new BadRequestException( |
|||
`Asset profile not found for ${symbol} (${dataSource})` |
|||
); |
|||
} |
|||
|
|||
return this.symbolProfileService.add( |
|||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput |
|||
); |
|||
} catch (error) { |
|||
if ( |
|||
error instanceof Prisma.PrismaClientKnownRequestError && |
|||
error.code === 'P2002' |
|||
) { |
|||
throw new BadRequestException( |
|||
`Asset profile of ${symbol} (${dataSource}) already exists` |
|||
); |
|||
} |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
public async deleteProfileData({ |
|||
dataSource, |
|||
symbol |
|||
}: AssetProfileIdentifier) { |
|||
await this.marketDataService.deleteMany({ dataSource, symbol }); |
|||
|
|||
const currency = getCurrencyFromSymbol(symbol); |
|||
const customCurrencies = |
|||
await this.propertyService.getByKey<string[]>(PROPERTY_CURRENCIES); |
|||
|
|||
if (customCurrencies.includes(currency)) { |
|||
const updatedCustomCurrencies = customCurrencies.filter( |
|||
(customCurrency) => { |
|||
return customCurrency !== currency; |
|||
} |
|||
); |
|||
|
|||
await this.putSetting( |
|||
PROPERTY_CURRENCIES, |
|||
JSON.stringify(updatedCustomCurrencies) |
|||
); |
|||
} else { |
|||
await this.symbolProfileService.delete({ dataSource, symbol }); |
|||
} |
|||
} |
|||
|
|||
public async get(): Promise<AdminData> { |
|||
const dataSources = Object.values(DataSource); |
|||
|
|||
const [activitiesCount, enabledDataSources, settings, userCount] = |
|||
await Promise.all([ |
|||
this.prismaService.order.count(), |
|||
this.dataProviderService.getDataSources(), |
|||
this.propertyService.get(), |
|||
this.countUsersWithAnalytics() |
|||
]); |
|||
|
|||
const dataProviders = ( |
|||
await Promise.all( |
|||
dataSources.map(async (dataSource) => { |
|||
const assetProfileCount = |
|||
await this.prismaService.symbolProfile.count({ |
|||
where: { |
|||
dataSource |
|||
} |
|||
}); |
|||
|
|||
const isEnabled = enabledDataSources.includes(dataSource); |
|||
|
|||
if ( |
|||
assetProfileCount > 0 || |
|||
dataSource === 'GHOSTFOLIO' || |
|||
isEnabled |
|||
) { |
|||
const dataProviderInfo = this.dataProviderService |
|||
.getDataProvider(dataSource) |
|||
.getDataProviderInfo(); |
|||
|
|||
return { |
|||
...dataProviderInfo, |
|||
assetProfileCount, |
|||
useForExchangeRates: |
|||
dataSource === |
|||
this.dataProviderService.getDataSourceForExchangeRates() |
|||
}; |
|||
} |
|||
|
|||
return null; |
|||
}) |
|||
) |
|||
).filter(Boolean); |
|||
|
|||
return { |
|||
activitiesCount, |
|||
dataProviders, |
|||
settings, |
|||
userCount, |
|||
version: environment.version |
|||
}; |
|||
} |
|||
|
|||
public async getMarketData({ |
|||
filters, |
|||
presetId, |
|||
sortColumn, |
|||
sortDirection = 'asc', |
|||
skip, |
|||
take = Number.MAX_SAFE_INTEGER |
|||
}: { |
|||
filters?: Filter[]; |
|||
presetId?: MarketDataPreset; |
|||
skip?: number; |
|||
sortColumn?: string; |
|||
sortDirection?: Prisma.SortOrder; |
|||
take?: number; |
|||
}): Promise<AdminMarketData> { |
|||
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> = |
|||
[{ symbol: 'asc' }]; |
|||
const where: Prisma.SymbolProfileWhereInput = {}; |
|||
|
|||
if (presetId === 'BENCHMARKS') { |
|||
const benchmarkAssetProfiles = |
|||
await this.benchmarkService.getBenchmarkAssetProfiles(); |
|||
|
|||
where.id = { |
|||
in: benchmarkAssetProfiles.map(({ id }) => { |
|||
return id; |
|||
}) |
|||
}; |
|||
} else if (presetId === 'CURRENCIES') { |
|||
return this.getMarketDataForCurrencies(); |
|||
} else if ( |
|||
presetId === 'ETF_WITHOUT_COUNTRIES' || |
|||
presetId === 'ETF_WITHOUT_SECTORS' |
|||
) { |
|||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; |
|||
} else if (presetId === 'NO_ACTIVITIES') { |
|||
where.activities = { |
|||
none: {} |
|||
}; |
|||
} |
|||
|
|||
const searchQuery = filters.find(({ type }) => { |
|||
return type === 'SEARCH_QUERY'; |
|||
})?.id; |
|||
|
|||
const { |
|||
ASSET_SUB_CLASS: filtersByAssetSubClass, |
|||
DATA_SOURCE: filtersByDataSource |
|||
} = groupBy(filters, ({ type }) => { |
|||
return type; |
|||
}); |
|||
|
|||
const marketDataItems = await this.prismaService.marketData.groupBy({ |
|||
_count: true, |
|||
by: ['dataSource', 'symbol'] |
|||
}); |
|||
|
|||
if (filtersByAssetSubClass) { |
|||
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; |
|||
} |
|||
|
|||
if (filtersByDataSource) { |
|||
where.dataSource = DataSource[filtersByDataSource[0].id]; |
|||
} |
|||
|
|||
if (searchQuery) { |
|||
where.OR = [ |
|||
{ id: { mode: 'insensitive', startsWith: searchQuery } }, |
|||
{ isin: { mode: 'insensitive', startsWith: searchQuery } }, |
|||
{ name: { mode: 'insensitive', startsWith: searchQuery } }, |
|||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } } |
|||
]; |
|||
} |
|||
|
|||
if (sortColumn) { |
|||
orderBy = [{ [sortColumn]: sortDirection }]; |
|||
|
|||
if (sortColumn === 'activitiesCount') { |
|||
orderBy = [ |
|||
{ |
|||
activities: { |
|||
_count: sortDirection |
|||
} |
|||
} |
|||
]; |
|||
} |
|||
} |
|||
|
|||
const extendedPrismaClient = this.getExtendedPrismaClient(); |
|||
|
|||
try { |
|||
const symbolProfileResult = await Promise.all([ |
|||
extendedPrismaClient.symbolProfile.findMany({ |
|||
skip, |
|||
take, |
|||
where, |
|||
orderBy: [...orderBy, { id: sortDirection }], |
|||
select: { |
|||
_count: { |
|||
select: { |
|||
activities: true, |
|||
watchedBy: true |
|||
} |
|||
}, |
|||
activities: { |
|||
orderBy: [{ date: 'asc' }], |
|||
select: { date: true }, |
|||
take: 1 |
|||
}, |
|||
assetClass: true, |
|||
assetSubClass: true, |
|||
comment: true, |
|||
countries: true, |
|||
currency: true, |
|||
dataSource: true, |
|||
id: true, |
|||
isActive: true, |
|||
isUsedByUsersWithSubscription: true, |
|||
name: true, |
|||
scraperConfiguration: true, |
|||
sectors: true, |
|||
symbol: true, |
|||
SymbolProfileOverrides: true |
|||
} |
|||
}), |
|||
this.prismaService.symbolProfile.count({ where }) |
|||
]); |
|||
const assetProfiles = symbolProfileResult[0]; |
|||
let count = symbolProfileResult[1]; |
|||
|
|||
const lastMarketPrices = await this.prismaService.marketData.findMany({ |
|||
distinct: ['dataSource', 'symbol'], |
|||
orderBy: { date: 'desc' }, |
|||
select: { |
|||
dataSource: true, |
|||
marketPrice: true, |
|||
symbol: true |
|||
}, |
|||
where: { |
|||
dataSource: { |
|||
in: assetProfiles.map(({ dataSource }) => { |
|||
return dataSource; |
|||
}) |
|||
}, |
|||
symbol: { |
|||
in: assetProfiles.map(({ symbol }) => { |
|||
return symbol; |
|||
}) |
|||
} |
|||
} |
|||
}); |
|||
|
|||
const lastMarketPriceMap = new Map<string, number>(); |
|||
|
|||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { |
|||
lastMarketPriceMap.set( |
|||
getAssetProfileIdentifier({ dataSource, symbol }), |
|||
marketPrice |
|||
); |
|||
} |
|||
|
|||
let marketData: AdminMarketDataItem[] = await Promise.all( |
|||
assetProfiles.map( |
|||
async ({ |
|||
_count, |
|||
activities, |
|||
assetClass, |
|||
assetSubClass, |
|||
comment, |
|||
countries, |
|||
currency, |
|||
dataSource, |
|||
id, |
|||
isActive, |
|||
isUsedByUsersWithSubscription, |
|||
name, |
|||
sectors, |
|||
symbol, |
|||
SymbolProfileOverrides |
|||
}) => { |
|||
let countriesCount = countries ? Object.keys(countries).length : 0; |
|||
|
|||
const lastMarketPrice = lastMarketPriceMap.get( |
|||
getAssetProfileIdentifier({ dataSource, symbol }) |
|||
); |
|||
|
|||
const marketDataItemCount = |
|||
marketDataItems.find((marketDataItem) => { |
|||
return ( |
|||
marketDataItem.dataSource === dataSource && |
|||
marketDataItem.symbol === symbol |
|||
); |
|||
})?._count ?? 0; |
|||
|
|||
let sectorsCount = sectors ? Object.keys(sectors).length : 0; |
|||
|
|||
if (SymbolProfileOverrides) { |
|||
assetClass = SymbolProfileOverrides.assetClass ?? assetClass; |
|||
assetSubClass = |
|||
SymbolProfileOverrides.assetSubClass ?? assetSubClass; |
|||
|
|||
if ( |
|||
( |
|||
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray |
|||
)?.length > 0 |
|||
) { |
|||
countriesCount = ( |
|||
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray |
|||
).length; |
|||
} |
|||
|
|||
name = SymbolProfileOverrides.name ?? name; |
|||
|
|||
if ( |
|||
(SymbolProfileOverrides.sectors as unknown as Sector[]) |
|||
?.length > 0 |
|||
) { |
|||
sectorsCount = ( |
|||
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray |
|||
).length; |
|||
} |
|||
} |
|||
|
|||
return { |
|||
assetClass, |
|||
assetSubClass, |
|||
comment, |
|||
currency, |
|||
countriesCount, |
|||
dataSource, |
|||
id, |
|||
isActive, |
|||
lastMarketPrice, |
|||
name, |
|||
symbol, |
|||
marketDataItemCount, |
|||
sectorsCount, |
|||
activitiesCount: _count.activities, |
|||
date: activities?.[0]?.date, |
|||
isUsedByUsersWithSubscription: |
|||
await isUsedByUsersWithSubscription, |
|||
watchedByCount: _count.watchedBy |
|||
}; |
|||
} |
|||
) |
|||
); |
|||
|
|||
if (presetId) { |
|||
if (presetId === 'ETF_WITHOUT_COUNTRIES') { |
|||
marketData = marketData.filter(({ countriesCount }) => { |
|||
return countriesCount === 0; |
|||
}); |
|||
} else if (presetId === 'ETF_WITHOUT_SECTORS') { |
|||
marketData = marketData.filter(({ sectorsCount }) => { |
|||
return sectorsCount === 0; |
|||
}); |
|||
} |
|||
|
|||
count = marketData.length; |
|||
} |
|||
|
|||
return { |
|||
count, |
|||
marketData |
|||
}; |
|||
} finally { |
|||
await extendedPrismaClient.$disconnect(); |
|||
|
|||
Logger.debug('Disconnect extended prisma client', 'AdminService'); |
|||
} |
|||
} |
|||
|
|||
public async getMarketDataBySymbol({ |
|||
dataSource, |
|||
symbol |
|||
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> { |
|||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; |
|||
let currency: EnhancedSymbolProfile['currency'] = '-'; |
|||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; |
|||
|
|||
if (isCurrency(getCurrencyFromSymbol(symbol))) { |
|||
currency = getCurrencyFromSymbol(symbol); |
|||
({ activitiesCount, dateOfFirstActivity } = |
|||
await this.orderService.getStatisticsByCurrency(currency)); |
|||
} |
|||
|
|||
const [[assetProfile], marketData] = await Promise.all([ |
|||
this.symbolProfileService.getSymbolProfiles([ |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
} |
|||
]), |
|||
this.marketDataService.marketDataItems({ |
|||
orderBy: { |
|||
date: 'asc' |
|||
}, |
|||
where: { |
|||
dataSource, |
|||
symbol |
|||
} |
|||
}) |
|||
]); |
|||
|
|||
if (assetProfile) { |
|||
assetProfile.dataProviderInfo = this.dataProviderService |
|||
.getDataProvider(assetProfile.dataSource) |
|||
.getDataProviderInfo(); |
|||
} |
|||
|
|||
return { |
|||
marketData, |
|||
assetProfile: assetProfile ?? { |
|||
activitiesCount, |
|||
currency, |
|||
dataSource, |
|||
dateOfFirstActivity, |
|||
symbol, |
|||
isActive: true |
|||
} |
|||
}; |
|||
} |
|||
|
|||
public async getUser(id: string): Promise<AdminUserResponse> { |
|||
const [user] = await this.getUsersWithAnalytics({ |
|||
where: { id } |
|||
}); |
|||
|
|||
if (!user) { |
|||
throw new NotFoundException(`User with ID ${id} not found`); |
|||
} |
|||
|
|||
return user; |
|||
} |
|||
|
|||
public async getUsers({ |
|||
skip, |
|||
take = Number.MAX_SAFE_INTEGER |
|||
}: { |
|||
skip?: number; |
|||
take?: number; |
|||
}): Promise<AdminUsersResponse> { |
|||
const [count, users] = await Promise.all([ |
|||
this.countUsersWithAnalytics(), |
|||
this.getUsersWithAnalytics({ |
|||
skip, |
|||
take |
|||
}) |
|||
]); |
|||
|
|||
return { count, users }; |
|||
} |
|||
|
|||
public async patchAssetProfileData( |
|||
{ dataSource, symbol }: AssetProfileIdentifier, |
|||
{ |
|||
assetClass, |
|||
assetSubClass, |
|||
comment, |
|||
countries, |
|||
currency, |
|||
dataSource: newDataSource, |
|||
holdings, |
|||
isActive, |
|||
name, |
|||
scraperConfiguration, |
|||
sectors, |
|||
symbol: newSymbol, |
|||
symbolMapping, |
|||
url |
|||
}: Prisma.SymbolProfileUpdateInput |
|||
) { |
|||
if ( |
|||
newSymbol && |
|||
newDataSource && |
|||
(newSymbol !== symbol || newDataSource !== dataSource) |
|||
) { |
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ |
|||
dataSource: DataSource[newDataSource.toString()], |
|||
symbol: newSymbol as string |
|||
} |
|||
]); |
|||
|
|||
if (assetProfile) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.CONFLICT), |
|||
StatusCodes.CONFLICT |
|||
); |
|||
} |
|||
|
|||
try { |
|||
Promise.all([ |
|||
await this.symbolProfileService.updateAssetProfileIdentifier( |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
}, |
|||
{ |
|||
dataSource: DataSource[newDataSource.toString()], |
|||
symbol: newSymbol as string |
|||
} |
|||
), |
|||
await this.marketDataService.updateAssetProfileIdentifier( |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
}, |
|||
{ |
|||
dataSource: DataSource[newDataSource.toString()], |
|||
symbol: newSymbol as string |
|||
} |
|||
) |
|||
]); |
|||
|
|||
return this.symbolProfileService.getSymbolProfiles([ |
|||
{ |
|||
dataSource: DataSource[newDataSource.toString()], |
|||
symbol: newSymbol as string |
|||
} |
|||
])?.[0]; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
} else { |
|||
const symbolProfileOverrides = { |
|||
assetClass: assetClass as AssetClass, |
|||
assetSubClass: assetSubClass as AssetSubClass, |
|||
name: name as string, |
|||
url: url as string |
|||
}; |
|||
|
|||
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = { |
|||
comment, |
|||
countries, |
|||
currency, |
|||
dataSource, |
|||
holdings, |
|||
isActive, |
|||
scraperConfiguration, |
|||
sectors, |
|||
symbol, |
|||
symbolMapping, |
|||
...(dataSource === 'MANUAL' |
|||
? { assetClass, assetSubClass, name, url } |
|||
: { |
|||
SymbolProfileOverrides: { |
|||
upsert: { |
|||
create: symbolProfileOverrides, |
|||
update: symbolProfileOverrides |
|||
} |
|||
} |
|||
}) |
|||
}; |
|||
|
|||
await this.symbolProfileService.updateSymbolProfile( |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
}, |
|||
updatedSymbolProfile |
|||
); |
|||
|
|||
return this.symbolProfileService.getSymbolProfiles([ |
|||
{ |
|||
dataSource: dataSource as DataSource, |
|||
symbol: symbol as string |
|||
} |
|||
])?.[0]; |
|||
} |
|||
} |
|||
|
|||
public async putSetting(key: string, value: string) { |
|||
let response: Property; |
|||
|
|||
if (value) { |
|||
response = await this.propertyService.put({ key, value }); |
|||
} else { |
|||
response = await this.propertyService.delete({ key }); |
|||
} |
|||
|
|||
if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') { |
|||
await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false'); |
|||
} else if (key === PROPERTY_CURRENCIES) { |
|||
await this.exchangeRateDataService.initialize(); |
|||
} |
|||
|
|||
return response; |
|||
} |
|||
|
|||
private async countUsersWithAnalytics() { |
|||
let where: Prisma.UserWhereInput; |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
|||
where = { |
|||
NOT: { |
|||
analytics: null |
|||
} |
|||
}; |
|||
} |
|||
|
|||
return this.prismaService.user.count({ |
|||
where |
|||
}); |
|||
} |
|||
|
|||
private getExtendedPrismaClient() { |
|||
Logger.debug('Connect extended prisma client', 'AdminService'); |
|||
|
|||
const symbolProfileExtension = Prisma.defineExtension((client) => { |
|||
return client.$extends({ |
|||
result: { |
|||
symbolProfile: { |
|||
isUsedByUsersWithSubscription: { |
|||
compute: async ({ id }) => { |
|||
const { _count } = |
|||
await this.prismaService.symbolProfile.findUnique({ |
|||
select: { |
|||
_count: { |
|||
select: { |
|||
activities: { |
|||
where: { |
|||
user: { |
|||
subscriptions: { |
|||
some: { |
|||
expiresAt: { |
|||
gt: new Date() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
where: { |
|||
id |
|||
} |
|||
}); |
|||
|
|||
return _count.activities > 0; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
return new PrismaClient().$extends(symbolProfileExtension); |
|||
} |
|||
|
|||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> { |
|||
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); |
|||
|
|||
const [lastMarketPrices, marketDataItems] = await Promise.all([ |
|||
this.prismaService.marketData.findMany({ |
|||
distinct: ['dataSource', 'symbol'], |
|||
orderBy: { date: 'desc' }, |
|||
select: { |
|||
dataSource: true, |
|||
marketPrice: true, |
|||
symbol: true |
|||
}, |
|||
where: { |
|||
dataSource: { |
|||
in: currencyPairs.map(({ dataSource }) => { |
|||
return dataSource; |
|||
}) |
|||
}, |
|||
symbol: { |
|||
in: currencyPairs.map(({ symbol }) => { |
|||
return symbol; |
|||
}) |
|||
} |
|||
} |
|||
}), |
|||
this.prismaService.marketData.groupBy({ |
|||
_count: true, |
|||
by: ['dataSource', 'symbol'] |
|||
}) |
|||
]); |
|||
|
|||
const lastMarketPriceMap = new Map<string, number>(); |
|||
|
|||
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { |
|||
lastMarketPriceMap.set( |
|||
getAssetProfileIdentifier({ dataSource, symbol }), |
|||
marketPrice |
|||
); |
|||
} |
|||
|
|||
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map( |
|||
async ({ dataSource, symbol }) => { |
|||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; |
|||
let currency: EnhancedSymbolProfile['currency'] = '-'; |
|||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; |
|||
|
|||
if (isCurrency(getCurrencyFromSymbol(symbol))) { |
|||
currency = getCurrencyFromSymbol(symbol); |
|||
({ activitiesCount, dateOfFirstActivity } = |
|||
await this.orderService.getStatisticsByCurrency(currency)); |
|||
} |
|||
|
|||
const lastMarketPrice = lastMarketPriceMap.get( |
|||
getAssetProfileIdentifier({ dataSource, symbol }) |
|||
); |
|||
|
|||
const marketDataItemCount = |
|||
marketDataItems.find((marketDataItem) => { |
|||
return ( |
|||
marketDataItem.dataSource === dataSource && |
|||
marketDataItem.symbol === symbol |
|||
); |
|||
})?._count ?? 0; |
|||
|
|||
return { |
|||
activitiesCount, |
|||
currency, |
|||
dataSource, |
|||
lastMarketPrice, |
|||
marketDataItemCount, |
|||
symbol, |
|||
assetClass: AssetClass.LIQUIDITY, |
|||
assetSubClass: AssetSubClass.CASH, |
|||
countriesCount: 0, |
|||
date: dateOfFirstActivity, |
|||
id: undefined, |
|||
isActive: true, |
|||
name: symbol, |
|||
sectorsCount: 0, |
|||
watchedByCount: 0 |
|||
}; |
|||
} |
|||
); |
|||
|
|||
const marketData = await Promise.all(marketDataPromise); |
|||
return { marketData, count: marketData.length }; |
|||
} |
|||
|
|||
private async getUsersWithAnalytics({ |
|||
skip, |
|||
take, |
|||
where |
|||
}: { |
|||
skip?: number; |
|||
take?: number; |
|||
where?: Prisma.UserWhereInput; |
|||
}): Promise<AdminUsersResponse['users']> { |
|||
let orderBy: Prisma.Enumerable<Prisma.UserOrderByWithRelationInput> = [ |
|||
{ createdAt: 'desc' } |
|||
]; |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
|||
orderBy = [ |
|||
{ |
|||
analytics: { |
|||
lastRequestAt: 'desc' |
|||
} |
|||
} |
|||
]; |
|||
|
|||
const noAnalyticsCondition: Prisma.UserWhereInput['NOT'] = { |
|||
analytics: null |
|||
}; |
|||
|
|||
if (where) { |
|||
if (where.NOT) { |
|||
where.NOT = { ...where.NOT, ...noAnalyticsCondition }; |
|||
} else { |
|||
where.NOT = noAnalyticsCondition; |
|||
} |
|||
} else { |
|||
where = { NOT: noAnalyticsCondition }; |
|||
} |
|||
} |
|||
|
|||
const usersWithAnalytics = await this.prismaService.user.findMany({ |
|||
skip, |
|||
take, |
|||
where, |
|||
orderBy: [...orderBy, { id: 'desc' }], |
|||
select: { |
|||
_count: { |
|||
select: { accounts: true, activities: true } |
|||
}, |
|||
analytics: { |
|||
select: { |
|||
activityCount: true, |
|||
country: true, |
|||
dataProviderGhostfolioDailyRequests: true, |
|||
updatedAt: true |
|||
} |
|||
}, |
|||
createdAt: true, |
|||
id: true, |
|||
provider: true, |
|||
role: true, |
|||
subscriptions: { |
|||
orderBy: { |
|||
expiresAt: 'desc' |
|||
}, |
|||
take: 1, |
|||
where: { |
|||
expiresAt: { |
|||
gt: new Date() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
}); |
|||
|
|||
return usersWithAnalytics.map( |
|||
({ _count, analytics, createdAt, id, provider, role, subscriptions }) => { |
|||
const daysSinceRegistration = |
|||
differenceInDays(new Date(), createdAt) + 1; |
|||
const engagement = analytics |
|||
? analytics.activityCount / daysSinceRegistration |
|||
: undefined; |
|||
|
|||
const subscription = |
|||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && |
|||
subscriptions?.length > 0 |
|||
? subscriptions[0] |
|||
: undefined; |
|||
|
|||
return { |
|||
createdAt, |
|||
engagement, |
|||
id, |
|||
provider, |
|||
role, |
|||
subscription, |
|||
accountCount: _count.accounts || 0, |
|||
activityCount: _count.activities || 0, |
|||
country: analytics?.country, |
|||
dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0, |
|||
lastActivity: analytics?.updatedAt |
|||
}; |
|||
} |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { AdminJobs } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
Param, |
|||
Query, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { JobStatus } from 'bull'; |
|||
|
|||
import { QueueService } from './queue.service'; |
|||
|
|||
@Controller('admin/queue') |
|||
export class QueueController { |
|||
public constructor(private readonly queueService: QueueService) {} |
|||
|
|||
@Delete('job') |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteJobs( |
|||
@Query('status') filterByStatus?: string |
|||
): Promise<void> { |
|||
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined; |
|||
return this.queueService.deleteJobs({ status }); |
|||
} |
|||
|
|||
@Get('job') |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getJobs( |
|||
@Query('status') filterByStatus?: string |
|||
): Promise<AdminJobs> { |
|||
const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined; |
|||
return this.queueService.getJobs({ status }); |
|||
} |
|||
|
|||
@Delete('job/:id') |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteJob(@Param('id') id: string): Promise<void> { |
|||
return this.queueService.deleteJob(id); |
|||
} |
|||
|
|||
@Get('job/:id/execute') |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async executeJob(@Param('id') id: string): Promise<void> { |
|||
return this.queueService.executeJob(id); |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
|||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { QueueController } from './queue.controller'; |
|||
import { QueueService } from './queue.service'; |
|||
|
|||
@Module({ |
|||
controllers: [QueueController], |
|||
imports: [DataGatheringModule, PortfolioSnapshotQueueModule], |
|||
providers: [QueueService] |
|||
}) |
|||
export class QueueModule {} |
|||
@ -0,0 +1,91 @@ |
|||
import { |
|||
DATA_GATHERING_QUEUE, |
|||
PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, |
|||
QUEUE_JOB_STATUS_LIST |
|||
} from '@ghostfolio/common/config'; |
|||
import { AdminJobs } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { InjectQueue } from '@nestjs/bull'; |
|||
import { Injectable } from '@nestjs/common'; |
|||
import { JobStatus, Queue } from 'bull'; |
|||
|
|||
@Injectable() |
|||
export class QueueService { |
|||
public constructor( |
|||
@InjectQueue(DATA_GATHERING_QUEUE) |
|||
private readonly dataGatheringQueue: Queue, |
|||
@InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) |
|||
private readonly portfolioSnapshotQueue: Queue |
|||
) {} |
|||
|
|||
public async deleteJob(aId: string) { |
|||
let job = await this.dataGatheringQueue.getJob(aId); |
|||
|
|||
if (!job) { |
|||
job = await this.portfolioSnapshotQueue.getJob(aId); |
|||
} |
|||
|
|||
return job?.remove(); |
|||
} |
|||
|
|||
public async deleteJobs({ |
|||
status = QUEUE_JOB_STATUS_LIST |
|||
}: { |
|||
status?: JobStatus[]; |
|||
}) { |
|||
for (const statusItem of status) { |
|||
const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem; |
|||
|
|||
await this.dataGatheringQueue.clean(300, queueStatus); |
|||
await this.portfolioSnapshotQueue.clean(300, queueStatus); |
|||
} |
|||
} |
|||
|
|||
public async executeJob(aId: string) { |
|||
let job = await this.dataGatheringQueue.getJob(aId); |
|||
|
|||
if (!job) { |
|||
job = await this.portfolioSnapshotQueue.getJob(aId); |
|||
} |
|||
|
|||
return job?.promote(); |
|||
} |
|||
|
|||
public async getJobs({ |
|||
limit = 1000, |
|||
status = QUEUE_JOB_STATUS_LIST |
|||
}: { |
|||
limit?: number; |
|||
status?: JobStatus[]; |
|||
}): Promise<AdminJobs> { |
|||
const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([ |
|||
this.dataGatheringQueue.getJobs(status), |
|||
this.portfolioSnapshotQueue.getJobs(status) |
|||
]); |
|||
|
|||
const jobsWithState = await Promise.all( |
|||
[...dataGatheringJobs, ...portfolioSnapshotJobs] |
|||
.filter((job) => { |
|||
return job; |
|||
}) |
|||
.slice(0, limit) |
|||
.map(async (job) => { |
|||
return { |
|||
attemptsMade: job.attemptsMade, |
|||
data: job.data, |
|||
finishedOn: job.finishedOn, |
|||
id: job.id, |
|||
name: job.name, |
|||
opts: job.opts, |
|||
stacktrace: job.stacktrace, |
|||
state: await job.getState(), |
|||
timestamp: job.timestamp |
|||
}; |
|||
}) |
|||
); |
|||
|
|||
return { |
|||
jobs: jobsWithState |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
|
|||
import { Controller } from '@nestjs/common'; |
|||
|
|||
@Controller() |
|||
export class AppController { |
|||
public constructor( |
|||
private readonly exchangeRateDataService: ExchangeRateDataService |
|||
) { |
|||
this.initialize(); |
|||
} |
|||
|
|||
private async initialize() { |
|||
try { |
|||
await this.exchangeRateDataService.initialize(); |
|||
} catch {} |
|||
} |
|||
} |
|||
@ -0,0 +1,148 @@ |
|||
import { EventsModule } from '@ghostfolio/api/events/events.module'; |
|||
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { CronModule } from '@ghostfolio/api/services/cron/cron.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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
|||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; |
|||
import { |
|||
DEFAULT_LANGUAGE_CODE, |
|||
SUPPORTED_LANGUAGE_CODES |
|||
} from '@ghostfolio/common/config'; |
|||
|
|||
import { BullModule } from '@nestjs/bull'; |
|||
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; |
|||
import { ConfigModule } from '@nestjs/config'; |
|||
import { EventEmitterModule } from '@nestjs/event-emitter'; |
|||
import { ScheduleModule } from '@nestjs/schedule'; |
|||
import { ServeStaticModule } from '@nestjs/serve-static'; |
|||
import { StatusCodes } from 'http-status-codes'; |
|||
import { join } from 'node:path'; |
|||
|
|||
import { AccessModule } from './access/access.module'; |
|||
import { AccountModule } from './account/account.module'; |
|||
import { AdminModule } from './admin/admin.module'; |
|||
import { AppController } from './app.controller'; |
|||
import { AssetModule } from './asset/asset.module'; |
|||
import { AuthDeviceModule } from './auth-device/auth-device.module'; |
|||
import { AuthModule } from './auth/auth.module'; |
|||
import { CacheModule } from './cache/cache.module'; |
|||
import { AiModule } from './endpoints/ai/ai.module'; |
|||
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; |
|||
import { AssetsModule } from './endpoints/assets/assets.module'; |
|||
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; |
|||
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; |
|||
import { MarketDataModule } from './endpoints/market-data/market-data.module'; |
|||
import { PlatformsModule } from './endpoints/platforms/platforms.module'; |
|||
import { PublicModule } from './endpoints/public/public.module'; |
|||
import { SitemapModule } from './endpoints/sitemap/sitemap.module'; |
|||
import { TagsModule } from './endpoints/tags/tags.module'; |
|||
import { WatchlistModule } from './endpoints/watchlist/watchlist.module'; |
|||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; |
|||
import { ExportModule } from './export/export.module'; |
|||
import { HealthModule } from './health/health.module'; |
|||
import { ImportModule } from './import/import.module'; |
|||
import { InfoModule } from './info/info.module'; |
|||
import { LogoModule } from './logo/logo.module'; |
|||
import { OrderModule } from './order/order.module'; |
|||
import { PlatformModule } from './platform/platform.module'; |
|||
import { PortfolioModule } from './portfolio/portfolio.module'; |
|||
import { RedisCacheModule } from './redis-cache/redis-cache.module'; |
|||
import { SubscriptionModule } from './subscription/subscription.module'; |
|||
import { SymbolModule } from './symbol/symbol.module'; |
|||
import { UserModule } from './user/user.module'; |
|||
|
|||
@Module({ |
|||
controllers: [AppController], |
|||
imports: [ |
|||
AdminModule, |
|||
AccessModule, |
|||
AccountModule, |
|||
AiModule, |
|||
ApiKeysModule, |
|||
AssetModule, |
|||
AssetsModule, |
|||
AuthDeviceModule, |
|||
AuthModule, |
|||
BenchmarksModule, |
|||
BullModule.forRoot({ |
|||
redis: { |
|||
db: parseInt(process.env.REDIS_DB ?? '0', 10), |
|||
host: process.env.REDIS_HOST, |
|||
password: process.env.REDIS_PASSWORD, |
|||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10) |
|||
} |
|||
}), |
|||
CacheModule, |
|||
ConfigModule.forRoot(), |
|||
ConfigurationModule, |
|||
CronModule, |
|||
DataGatheringModule, |
|||
DataProviderModule, |
|||
EventEmitterModule.forRoot(), |
|||
EventsModule, |
|||
ExchangeRateModule, |
|||
ExchangeRateDataModule, |
|||
ExportModule, |
|||
GhostfolioModule, |
|||
HealthModule, |
|||
ImportModule, |
|||
InfoModule, |
|||
LogoModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
PlatformModule, |
|||
PlatformsModule, |
|||
PortfolioModule, |
|||
PortfolioSnapshotQueueModule, |
|||
PrismaModule, |
|||
PropertyModule, |
|||
PublicModule, |
|||
RedisCacheModule, |
|||
ScheduleModule.forRoot(), |
|||
ServeStaticModule.forRoot({ |
|||
exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'], |
|||
rootPath: join(__dirname, '..', 'client'), |
|||
serveStaticOptions: { |
|||
setHeaders: (res) => { |
|||
if (res.req?.path === '/') { |
|||
let languageCode = DEFAULT_LANGUAGE_CODE; |
|||
|
|||
try { |
|||
const code = res.req.headers['accept-language'] |
|||
.split(',')[0] |
|||
.split('-')[0]; |
|||
|
|||
if (SUPPORTED_LANGUAGE_CODES.includes(code)) { |
|||
languageCode = code; |
|||
} |
|||
} catch {} |
|||
|
|||
res.set('Location', `/${languageCode}`); |
|||
res.statusCode = StatusCodes.MOVED_PERMANENTLY; |
|||
} |
|||
} |
|||
} |
|||
}), |
|||
ServeStaticModule.forRoot({ |
|||
rootPath: join(__dirname, '..', 'client', '.well-known'), |
|||
serveRoot: '/.well-known' |
|||
}), |
|||
SitemapModule, |
|||
SubscriptionModule, |
|||
SymbolModule, |
|||
TagsModule, |
|||
UserModule, |
|||
WatchlistModule |
|||
], |
|||
providers: [I18nService] |
|||
}) |
|||
export class AppModule implements NestModule { |
|||
public configure(consumer: MiddlewareConsumer) { |
|||
consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard'); |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import type { AssetResponse } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Controller, Get, Param, UseInterceptors } from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { pick } from 'lodash'; |
|||
|
|||
@Controller('asset') |
|||
export class AssetController { |
|||
public constructor(private readonly adminService: AdminService) {} |
|||
|
|||
@Get(':dataSource/:symbol') |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getAsset( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<AssetResponse> { |
|||
const { assetProfile, marketData } = |
|||
await this.adminService.getMarketDataBySymbol({ dataSource, symbol }); |
|||
|
|||
return { |
|||
marketData, |
|||
assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AssetController } from './asset.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [AssetController], |
|||
imports: [ |
|||
AdminModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule |
|||
] |
|||
}) |
|||
export class AssetModule {} |
|||
@ -0,0 +1,19 @@ |
|||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { Controller, Delete, Param, UseGuards } from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
@Controller('auth-device') |
|||
export class AuthDeviceController { |
|||
public constructor(private readonly authDeviceService: AuthDeviceService) {} |
|||
|
|||
@Delete(':id') |
|||
@HasPermission(permissions.deleteAuthDevice) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> { |
|||
await this.authDeviceService.deleteAuthDevice({ id }); |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; |
|||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
import { JwtModule } from '@nestjs/jwt'; |
|||
|
|||
@Module({ |
|||
controllers: [AuthDeviceController], |
|||
imports: [ |
|||
JwtModule.register({ |
|||
secret: process.env.JWT_SECRET_KEY, |
|||
signOptions: { expiresIn: '180 days' } |
|||
}), |
|||
PrismaModule |
|||
], |
|||
providers: [AuthDeviceService] |
|||
}) |
|||
export class AuthDeviceModule {} |
|||
@ -0,0 +1,62 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { AuthDevice, Prisma } from '@prisma/client'; |
|||
|
|||
@Injectable() |
|||
export class AuthDeviceService { |
|||
public constructor(private readonly prismaService: PrismaService) {} |
|||
|
|||
public async authDevice( |
|||
where: Prisma.AuthDeviceWhereUniqueInput |
|||
): Promise<AuthDevice | null> { |
|||
return this.prismaService.authDevice.findUnique({ |
|||
where |
|||
}); |
|||
} |
|||
|
|||
public async authDevices(params: { |
|||
skip?: number; |
|||
take?: number; |
|||
cursor?: Prisma.AuthDeviceWhereUniqueInput; |
|||
where?: Prisma.AuthDeviceWhereInput; |
|||
orderBy?: Prisma.AuthDeviceOrderByWithRelationInput; |
|||
}): Promise<AuthDevice[]> { |
|||
const { skip, take, cursor, where, orderBy } = params; |
|||
return this.prismaService.authDevice.findMany({ |
|||
skip, |
|||
take, |
|||
cursor, |
|||
where, |
|||
orderBy |
|||
}); |
|||
} |
|||
|
|||
public async createAuthDevice( |
|||
data: Prisma.AuthDeviceCreateInput |
|||
): Promise<AuthDevice> { |
|||
return this.prismaService.authDevice.create({ |
|||
data |
|||
}); |
|||
} |
|||
|
|||
public async updateAuthDevice(params: { |
|||
data: Prisma.AuthDeviceUpdateInput; |
|||
where: Prisma.AuthDeviceWhereUniqueInput; |
|||
}): Promise<AuthDevice> { |
|||
const { data, where } = params; |
|||
|
|||
return this.prismaService.authDevice.update({ |
|||
data, |
|||
where |
|||
}); |
|||
} |
|||
|
|||
public async deleteAuthDevice( |
|||
where: Prisma.AuthDeviceWhereUniqueInput |
|||
): Promise<AuthDevice> { |
|||
return this.prismaService.authDevice.delete({ |
|||
where |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,70 @@ |
|||
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
|||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; |
|||
import { hasRole } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { HttpException, Injectable } from '@nestjs/common'; |
|||
import { PassportStrategy } from '@nestjs/passport'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; |
|||
|
|||
@Injectable() |
|||
export class ApiKeyStrategy extends PassportStrategy( |
|||
HeaderAPIKeyStrategy, |
|||
'api-key' |
|||
) { |
|||
public constructor( |
|||
private readonly apiKeyService: ApiKeyService, |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly userService: UserService |
|||
) { |
|||
super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false); |
|||
} |
|||
|
|||
public async validate(apiKey: string) { |
|||
const user = await this.validateApiKey(apiKey); |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
|||
if (hasRole(user, 'INACTIVE')) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
await this.prismaService.analytics.upsert({ |
|||
create: { user: { connect: { id: user.id } } }, |
|||
update: { |
|||
activityCount: { increment: 1 }, |
|||
lastRequestAt: new Date() |
|||
}, |
|||
where: { userId: user.id } |
|||
}); |
|||
} |
|||
|
|||
return user; |
|||
} |
|||
|
|||
private async validateApiKey(apiKey: string) { |
|||
if (!apiKey) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.UNAUTHORIZED), |
|||
StatusCodes.UNAUTHORIZED |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const { id } = await this.apiKeyService.getUserByApiKey(apiKey); |
|||
|
|||
return this.userService.user({ id }); |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.UNAUTHORIZED), |
|||
StatusCodes.UNAUTHORIZED |
|||
); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,175 @@ |
|||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; |
|||
import { |
|||
AssertionCredentialJSON, |
|||
AttestationCredentialJSON, |
|||
OAuthResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Param, |
|||
Post, |
|||
Req, |
|||
Res, |
|||
UseGuards, |
|||
Version, |
|||
VERSION_NEUTRAL |
|||
} from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { Request, Response } from 'express'; |
|||
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
|||
|
|||
import { AuthService } from './auth.service'; |
|||
|
|||
@Controller('auth') |
|||
export class AuthController { |
|||
public constructor( |
|||
private readonly authService: AuthService, |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly webAuthService: WebAuthService |
|||
) {} |
|||
|
|||
/** |
|||
* @deprecated |
|||
*/ |
|||
@Get('anonymous/:accessToken') |
|||
public async accessTokenLoginGet( |
|||
@Param('accessToken') accessToken: string |
|||
): Promise<OAuthResponse> { |
|||
try { |
|||
const authToken = |
|||
await this.authService.validateAnonymousLogin(accessToken); |
|||
return { authToken }; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Post('anonymous') |
|||
public async accessTokenLogin( |
|||
@Body() body: { accessToken: string } |
|||
): Promise<OAuthResponse> { |
|||
try { |
|||
const authToken = await this.authService.validateAnonymousLogin( |
|||
body.accessToken |
|||
); |
|||
return { authToken }; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('google') |
|||
@UseGuards(AuthGuard('google')) |
|||
public googleLogin() { |
|||
// Initiates the Google OAuth2 login flow
|
|||
} |
|||
|
|||
@Get('google/callback') |
|||
@UseGuards(AuthGuard('google')) |
|||
@Version(VERSION_NEUTRAL) |
|||
public googleLoginCallback( |
|||
@Req() request: Request, |
|||
@Res() response: Response |
|||
) { |
|||
const jwt: string = (request.user as any).jwt; |
|||
|
|||
if (jwt) { |
|||
response.redirect( |
|||
`${this.configurationService.get( |
|||
'ROOT_URL' |
|||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` |
|||
); |
|||
} else { |
|||
response.redirect( |
|||
`${this.configurationService.get( |
|||
'ROOT_URL' |
|||
)}/${DEFAULT_LANGUAGE_CODE}/auth` |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('oidc') |
|||
@UseGuards(AuthGuard('oidc')) |
|||
@Version(VERSION_NEUTRAL) |
|||
public oidcLogin() { |
|||
if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('oidc/callback') |
|||
@UseGuards(AuthGuard('oidc')) |
|||
@Version(VERSION_NEUTRAL) |
|||
public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { |
|||
const jwt: string = (request.user as any).jwt; |
|||
|
|||
if (jwt) { |
|||
response.redirect( |
|||
`${this.configurationService.get( |
|||
'ROOT_URL' |
|||
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` |
|||
); |
|||
} else { |
|||
response.redirect( |
|||
`${this.configurationService.get( |
|||
'ROOT_URL' |
|||
)}/${DEFAULT_LANGUAGE_CODE}/auth` |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Post('webauthn/generate-authentication-options') |
|||
public async generateAuthenticationOptions( |
|||
@Body() body: { deviceId: string } |
|||
) { |
|||
return this.webAuthService.generateAuthenticationOptions(body.deviceId); |
|||
} |
|||
|
|||
@Get('webauthn/generate-registration-options') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async generateRegistrationOptions() { |
|||
return this.webAuthService.generateRegistrationOptions(); |
|||
} |
|||
|
|||
@Post('webauthn/verify-attestation') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async verifyAttestation( |
|||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON } |
|||
) { |
|||
return this.webAuthService.verifyAttestation(body.credential); |
|||
} |
|||
|
|||
@Post('webauthn/verify-authentication') |
|||
public async verifyAuthentication( |
|||
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } |
|||
) { |
|||
try { |
|||
const authToken = await this.webAuthService.verifyAuthentication( |
|||
body.deviceId, |
|||
body.credential |
|||
); |
|||
return { authToken }; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,122 @@ |
|||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; |
|||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; |
|||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; |
|||
import { UserModule } from '@ghostfolio/api/app/user/user.module'; |
|||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
|
|||
import { Logger, Module } from '@nestjs/common'; |
|||
import { JwtModule } from '@nestjs/jwt'; |
|||
import type { StrategyOptions } from 'passport-openidconnect'; |
|||
|
|||
import { ApiKeyStrategy } from './api-key.strategy'; |
|||
import { AuthController } from './auth.controller'; |
|||
import { AuthService } from './auth.service'; |
|||
import { GoogleStrategy } from './google.strategy'; |
|||
import { JwtStrategy } from './jwt.strategy'; |
|||
import { OidcStrategy } from './oidc.strategy'; |
|||
|
|||
@Module({ |
|||
controllers: [AuthController], |
|||
imports: [ |
|||
ConfigurationModule, |
|||
JwtModule.register({ |
|||
secret: process.env.JWT_SECRET_KEY, |
|||
signOptions: { expiresIn: '180 days' } |
|||
}), |
|||
PrismaModule, |
|||
PropertyModule, |
|||
SubscriptionModule, |
|||
UserModule |
|||
], |
|||
providers: [ |
|||
ApiKeyService, |
|||
ApiKeyStrategy, |
|||
AuthDeviceService, |
|||
AuthService, |
|||
GoogleStrategy, |
|||
JwtStrategy, |
|||
{ |
|||
inject: [AuthService, ConfigurationService], |
|||
provide: OidcStrategy, |
|||
useFactory: async ( |
|||
authService: AuthService, |
|||
configurationService: ConfigurationService |
|||
) => { |
|||
const isOidcEnabled = configurationService.get( |
|||
'ENABLE_FEATURE_AUTH_OIDC' |
|||
); |
|||
|
|||
if (!isOidcEnabled) { |
|||
return null; |
|||
} |
|||
|
|||
const issuer = configurationService.get('OIDC_ISSUER'); |
|||
const scope = configurationService.get('OIDC_SCOPE'); |
|||
|
|||
const callbackUrl = |
|||
configurationService.get('OIDC_CALLBACK_URL') || |
|||
`${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`; |
|||
|
|||
// Check for manual URL overrides
|
|||
const manualAuthorizationUrl = configurationService.get( |
|||
'OIDC_AUTHORIZATION_URL' |
|||
); |
|||
const manualTokenUrl = configurationService.get('OIDC_TOKEN_URL'); |
|||
const manualUserInfoUrl = |
|||
configurationService.get('OIDC_USER_INFO_URL'); |
|||
|
|||
let authorizationURL: string; |
|||
let tokenURL: string; |
|||
let userInfoURL: string; |
|||
|
|||
if (manualAuthorizationUrl && manualTokenUrl && manualUserInfoUrl) { |
|||
// Use manual URLs
|
|||
authorizationURL = manualAuthorizationUrl; |
|||
tokenURL = manualTokenUrl; |
|||
userInfoURL = manualUserInfoUrl; |
|||
} else { |
|||
// Fetch OIDC configuration from discovery endpoint
|
|||
try { |
|||
const response = await fetch( |
|||
`${issuer}/.well-known/openid-configuration` |
|||
); |
|||
|
|||
const config = (await response.json()) as { |
|||
authorization_endpoint: string; |
|||
token_endpoint: string; |
|||
userinfo_endpoint: string; |
|||
}; |
|||
|
|||
// Manual URLs take priority over discovered ones
|
|||
authorizationURL = |
|||
manualAuthorizationUrl || config.authorization_endpoint; |
|||
tokenURL = manualTokenUrl || config.token_endpoint; |
|||
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint; |
|||
} catch (error) { |
|||
Logger.error(error, 'OidcStrategy'); |
|||
throw new Error('Failed to fetch OIDC configuration from issuer'); |
|||
} |
|||
} |
|||
|
|||
const options: StrategyOptions = { |
|||
authorizationURL, |
|||
issuer, |
|||
scope, |
|||
tokenURL, |
|||
userInfoURL, |
|||
callbackURL: callbackUrl, |
|||
clientID: configurationService.get('OIDC_CLIENT_ID'), |
|||
clientSecret: configurationService.get('OIDC_CLIENT_SECRET') |
|||
}; |
|||
|
|||
return new OidcStrategy(authService, options); |
|||
} |
|||
}, |
|||
WebAuthService |
|||
] |
|||
}) |
|||
export class AuthModule {} |
|||
@ -0,0 +1,74 @@ |
|||
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
|
|||
import { Injectable, InternalServerErrorException } from '@nestjs/common'; |
|||
import { JwtService } from '@nestjs/jwt'; |
|||
|
|||
import { ValidateOAuthLoginParams } from './interfaces/interfaces'; |
|||
|
|||
@Injectable() |
|||
export class AuthService { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly jwtService: JwtService, |
|||
private readonly propertyService: PropertyService, |
|||
private readonly userService: UserService |
|||
) {} |
|||
|
|||
public async validateAnonymousLogin(accessToken: string): Promise<string> { |
|||
const hashedAccessToken = this.userService.createAccessToken({ |
|||
password: accessToken, |
|||
salt: this.configurationService.get('ACCESS_TOKEN_SALT') |
|||
}); |
|||
|
|||
const [user] = await this.userService.users({ |
|||
where: { accessToken: hashedAccessToken } |
|||
}); |
|||
|
|||
if (user) { |
|||
return this.jwtService.sign({ |
|||
id: user.id |
|||
}); |
|||
} |
|||
|
|||
throw new Error(); |
|||
} |
|||
|
|||
public async validateOAuthLogin({ |
|||
provider, |
|||
thirdPartyId |
|||
}: ValidateOAuthLoginParams): Promise<string> { |
|||
try { |
|||
let [user] = await this.userService.users({ |
|||
where: { provider, thirdPartyId } |
|||
}); |
|||
|
|||
if (!user) { |
|||
const isUserSignupEnabled = |
|||
await this.propertyService.isUserSignupEnabled(); |
|||
|
|||
if (!isUserSignupEnabled) { |
|||
throw new Error('Sign up forbidden'); |
|||
} |
|||
|
|||
// Create new user if not found
|
|||
user = await this.userService.createUser({ |
|||
data: { |
|||
provider, |
|||
thirdPartyId |
|||
} |
|||
}); |
|||
} |
|||
|
|||
return this.jwtService.sign({ |
|||
id: user.id |
|||
}); |
|||
} catch (error) { |
|||
throw new InternalServerErrorException( |
|||
'validateOAuthLogin', |
|||
error instanceof Error ? error.message : 'Unknown error' |
|||
); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { PassportStrategy } from '@nestjs/passport'; |
|||
import { Provider } from '@prisma/client'; |
|||
import { DoneCallback } from 'passport'; |
|||
import { Profile, Strategy } from 'passport-google-oauth20'; |
|||
|
|||
import { AuthService } from './auth.service'; |
|||
|
|||
@Injectable() |
|||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { |
|||
public constructor( |
|||
private readonly authService: AuthService, |
|||
configurationService: ConfigurationService |
|||
) { |
|||
super({ |
|||
callbackURL: `${configurationService.get( |
|||
'ROOT_URL' |
|||
)}/api/auth/google/callback`,
|
|||
clientID: configurationService.get('GOOGLE_CLIENT_ID'), |
|||
clientSecret: configurationService.get('GOOGLE_SECRET'), |
|||
passReqToCallback: true, |
|||
scope: ['profile'] |
|||
}); |
|||
} |
|||
|
|||
public async validate( |
|||
_request: any, |
|||
_token: string, |
|||
_refreshToken: string, |
|||
profile: Profile, |
|||
done: DoneCallback |
|||
) { |
|||
try { |
|||
const jwt = await this.authService.validateOAuthLogin({ |
|||
provider: Provider.GOOGLE, |
|||
thirdPartyId: profile.id |
|||
}); |
|||
|
|||
done(null, { jwt }); |
|||
} catch (error) { |
|||
Logger.error(error, 'GoogleStrategy'); |
|||
done(error, false); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
import { AuthDeviceDto } from '@ghostfolio/common/dtos'; |
|||
|
|||
import { Provider } from '@prisma/client'; |
|||
|
|||
export interface AuthDeviceDialogParams { |
|||
authDevice: AuthDeviceDto; |
|||
} |
|||
|
|||
export interface OidcContext { |
|||
claims?: { |
|||
sub?: string; |
|||
}; |
|||
} |
|||
|
|||
export interface OidcIdToken { |
|||
sub?: string; |
|||
} |
|||
|
|||
export interface OidcParams { |
|||
sub?: string; |
|||
} |
|||
|
|||
export interface OidcProfile { |
|||
id?: string; |
|||
sub?: string; |
|||
} |
|||
|
|||
export interface ValidateOAuthLoginParams { |
|||
provider: Provider; |
|||
thirdPartyId: string; |
|||
} |
|||
@ -0,0 +1,85 @@ |
|||
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { |
|||
DEFAULT_CURRENCY, |
|||
DEFAULT_LANGUAGE_CODE, |
|||
HEADER_KEY_TIMEZONE |
|||
} from '@ghostfolio/common/config'; |
|||
import { hasRole } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { HttpException, Injectable } from '@nestjs/common'; |
|||
import { PassportStrategy } from '@nestjs/passport'; |
|||
import * as countriesAndTimezones from 'countries-and-timezones'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
import { ExtractJwt, Strategy } from 'passport-jwt'; |
|||
|
|||
@Injectable() |
|||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly userService: UserService |
|||
) { |
|||
super({ |
|||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), |
|||
passReqToCallback: true, |
|||
secretOrKey: configurationService.get('JWT_SECRET_KEY') |
|||
}); |
|||
} |
|||
|
|||
public async validate(request: Request, { id }: { id: string }) { |
|||
try { |
|||
const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()]; |
|||
const user = await this.userService.user({ id }); |
|||
|
|||
if (user) { |
|||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
|||
if (hasRole(user, 'INACTIVE')) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
const country = |
|||
countriesAndTimezones.getCountryForTimezone(timezone)?.id; |
|||
|
|||
await this.prismaService.analytics.upsert({ |
|||
create: { country, user: { connect: { id: user.id } } }, |
|||
update: { |
|||
country, |
|||
activityCount: { increment: 1 }, |
|||
lastRequestAt: new Date() |
|||
}, |
|||
where: { userId: user.id } |
|||
}); |
|||
} |
|||
|
|||
if (!user.settings.settings.baseCurrency) { |
|||
user.settings.settings.baseCurrency = DEFAULT_CURRENCY; |
|||
} |
|||
|
|||
if (!user.settings.settings.language) { |
|||
user.settings.settings.language = DEFAULT_LANGUAGE_CODE; |
|||
} |
|||
|
|||
return user; |
|||
} else { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
} catch (error) { |
|||
if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) { |
|||
throw error; |
|||
} else { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.UNAUTHORIZED), |
|||
StatusCodes.UNAUTHORIZED |
|||
); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,114 @@ |
|||
import ms from 'ms'; |
|||
|
|||
/** |
|||
* Custom state store for OIDC authentication that doesn't rely on express-session. |
|||
* This store manages OAuth2 state parameters in memory with automatic cleanup. |
|||
*/ |
|||
export class OidcStateStore { |
|||
private readonly STATE_EXPIRY_MS = ms('10 minutes'); |
|||
|
|||
private stateMap = new Map< |
|||
string, |
|||
{ |
|||
appState?: unknown; |
|||
ctx: { issued?: Date; maxAge?: number; nonce?: string }; |
|||
meta?: unknown; |
|||
timestamp: number; |
|||
} |
|||
>(); |
|||
|
|||
/** |
|||
* Store request state. |
|||
* Signature matches passport-openidconnect SessionStore |
|||
*/ |
|||
public store( |
|||
_req: unknown, |
|||
_meta: unknown, |
|||
appState: unknown, |
|||
ctx: { maxAge?: number; nonce?: string; issued?: Date }, |
|||
callback: (err: Error | null, handle?: string) => void |
|||
) { |
|||
try { |
|||
// Generate a unique handle for this state
|
|||
const handle = this.generateHandle(); |
|||
|
|||
this.stateMap.set(handle, { |
|||
appState, |
|||
ctx, |
|||
meta: _meta, |
|||
timestamp: Date.now() |
|||
}); |
|||
|
|||
// Clean up expired states
|
|||
this.cleanup(); |
|||
|
|||
callback(null, handle); |
|||
} catch (error) { |
|||
callback(error as Error); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Verify request state. |
|||
* Signature matches passport-openidconnect SessionStore |
|||
*/ |
|||
public verify( |
|||
_req: unknown, |
|||
handle: string, |
|||
callback: ( |
|||
err: Error | null, |
|||
appState?: unknown, |
|||
ctx?: { maxAge?: number; nonce?: string; issued?: Date } |
|||
) => void |
|||
) { |
|||
try { |
|||
const data = this.stateMap.get(handle); |
|||
|
|||
if (!data) { |
|||
return callback(null, undefined, undefined); |
|||
} |
|||
|
|||
if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) { |
|||
// State has expired
|
|||
this.stateMap.delete(handle); |
|||
return callback(null, undefined, undefined); |
|||
} |
|||
|
|||
// Remove state after verification (one-time use)
|
|||
this.stateMap.delete(handle); |
|||
|
|||
callback(null, data.ctx, data.appState); |
|||
} catch (error) { |
|||
callback(error as Error); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Clean up expired states |
|||
*/ |
|||
private cleanup() { |
|||
const now = Date.now(); |
|||
const expiredKeys: string[] = []; |
|||
|
|||
for (const [key, value] of this.stateMap.entries()) { |
|||
if (now - value.timestamp > this.STATE_EXPIRY_MS) { |
|||
expiredKeys.push(key); |
|||
} |
|||
} |
|||
|
|||
for (const key of expiredKeys) { |
|||
this.stateMap.delete(key); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Generate a cryptographically secure random handle |
|||
*/ |
|||
private generateHandle() { |
|||
return ( |
|||
Math.random().toString(36).substring(2, 15) + |
|||
Math.random().toString(36).substring(2, 15) + |
|||
Date.now().toString(36) |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,69 @@ |
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { PassportStrategy } from '@nestjs/passport'; |
|||
import { Provider } from '@prisma/client'; |
|||
import { Request } from 'express'; |
|||
import { Strategy, type StrategyOptions } from 'passport-openidconnect'; |
|||
|
|||
import { AuthService } from './auth.service'; |
|||
import { |
|||
OidcContext, |
|||
OidcIdToken, |
|||
OidcParams, |
|||
OidcProfile |
|||
} from './interfaces/interfaces'; |
|||
import { OidcStateStore } from './oidc-state.store'; |
|||
|
|||
@Injectable() |
|||
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { |
|||
private static readonly stateStore = new OidcStateStore(); |
|||
|
|||
public constructor( |
|||
private readonly authService: AuthService, |
|||
options: StrategyOptions |
|||
) { |
|||
super({ |
|||
...options, |
|||
passReqToCallback: true, |
|||
store: OidcStrategy.stateStore |
|||
}); |
|||
} |
|||
|
|||
public async validate( |
|||
_request: Request, |
|||
issuer: string, |
|||
profile: OidcProfile, |
|||
context: OidcContext, |
|||
idToken: OidcIdToken, |
|||
_accessToken: string, |
|||
_refreshToken: string, |
|||
params: OidcParams |
|||
) { |
|||
try { |
|||
const thirdPartyId = |
|||
profile?.id ?? |
|||
profile?.sub ?? |
|||
idToken?.sub ?? |
|||
params?.sub ?? |
|||
context?.claims?.sub; |
|||
|
|||
const jwt = await this.authService.validateOAuthLogin({ |
|||
thirdPartyId, |
|||
provider: Provider.OIDC |
|||
}); |
|||
|
|||
if (!thirdPartyId) { |
|||
Logger.error( |
|||
`Missing subject identifier in OIDC response from ${issuer}`, |
|||
'OidcStrategy' |
|||
); |
|||
|
|||
throw new Error('Missing subject identifier in OIDC response'); |
|||
} |
|||
|
|||
return { jwt }; |
|||
} catch (error) { |
|||
Logger.error(error, 'OidcStrategy'); |
|||
throw error; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,234 @@ |
|||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; |
|||
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { AuthDeviceDto } from '@ghostfolio/common/dtos'; |
|||
import { |
|||
AssertionCredentialJSON, |
|||
AttestationCredentialJSON |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Inject, |
|||
Injectable, |
|||
InternalServerErrorException, |
|||
Logger |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { JwtService } from '@nestjs/jwt'; |
|||
import { |
|||
generateAuthenticationOptions, |
|||
GenerateAuthenticationOptionsOpts, |
|||
generateRegistrationOptions, |
|||
GenerateRegistrationOptionsOpts, |
|||
VerifiedAuthenticationResponse, |
|||
VerifiedRegistrationResponse, |
|||
verifyAuthenticationResponse, |
|||
VerifyAuthenticationResponseOpts, |
|||
verifyRegistrationResponse, |
|||
VerifyRegistrationResponseOpts |
|||
} from '@simplewebauthn/server'; |
|||
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; |
|||
import ms from 'ms'; |
|||
|
|||
@Injectable() |
|||
export class WebAuthService { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly deviceService: AuthDeviceService, |
|||
private readonly jwtService: JwtService, |
|||
private readonly userService: UserService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
private get expectedOrigin() { |
|||
return this.configurationService.get('ROOT_URL'); |
|||
} |
|||
|
|||
private get rpID() { |
|||
return new URL(this.configurationService.get('ROOT_URL')).hostname; |
|||
} |
|||
|
|||
public async generateRegistrationOptions() { |
|||
const user = this.request.user; |
|||
|
|||
const opts: GenerateRegistrationOptionsOpts = { |
|||
authenticatorSelection: { |
|||
authenticatorAttachment: 'platform', |
|||
residentKey: 'required', |
|||
userVerification: 'preferred' |
|||
}, |
|||
rpID: this.rpID, |
|||
rpName: 'Ghostfolio', |
|||
timeout: ms('60 seconds'), |
|||
userID: isoUint8Array.fromUTF8String(user.id), |
|||
userName: '' |
|||
}; |
|||
|
|||
const registrationOptions = await generateRegistrationOptions(opts); |
|||
|
|||
await this.userService.updateUser({ |
|||
data: { |
|||
authChallenge: registrationOptions.challenge |
|||
}, |
|||
where: { |
|||
id: user.id |
|||
} |
|||
}); |
|||
|
|||
return registrationOptions; |
|||
} |
|||
|
|||
public async verifyAttestation( |
|||
credential: AttestationCredentialJSON |
|||
): Promise<AuthDeviceDto> { |
|||
const user = this.request.user; |
|||
const expectedChallenge = user.authChallenge; |
|||
let verification: VerifiedRegistrationResponse; |
|||
|
|||
try { |
|||
const opts: VerifyRegistrationResponseOpts = { |
|||
expectedChallenge, |
|||
expectedOrigin: this.expectedOrigin, |
|||
expectedRPID: this.rpID, |
|||
requireUserVerification: false, |
|||
response: { |
|||
clientExtensionResults: credential.clientExtensionResults, |
|||
id: credential.id, |
|||
rawId: credential.rawId, |
|||
response: credential.response, |
|||
type: 'public-key' |
|||
} |
|||
}; |
|||
|
|||
verification = await verifyRegistrationResponse(opts); |
|||
} catch (error) { |
|||
Logger.error(error, 'WebAuthService'); |
|||
throw new InternalServerErrorException(error.message); |
|||
} |
|||
|
|||
const { registrationInfo, verified } = verification; |
|||
|
|||
const devices = await this.deviceService.authDevices({ |
|||
where: { userId: user.id } |
|||
}); |
|||
if (registrationInfo && verified) { |
|||
const { |
|||
credential: { |
|||
counter, |
|||
id: credentialId, |
|||
publicKey: credentialPublicKey |
|||
} |
|||
} = registrationInfo; |
|||
|
|||
let existingDevice = devices.find((device) => { |
|||
return isoBase64URL.fromBuffer(device.credentialId) === credentialId; |
|||
}); |
|||
|
|||
if (!existingDevice) { |
|||
/** |
|||
* Add the returned device to the user's list of devices |
|||
*/ |
|||
existingDevice = await this.deviceService.createAuthDevice({ |
|||
counter, |
|||
credentialId: Buffer.from(credentialId), |
|||
credentialPublicKey: Buffer.from(credentialPublicKey), |
|||
user: { connect: { id: user.id } } |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
createdAt: existingDevice.createdAt.toISOString(), |
|||
id: existingDevice.id |
|||
}; |
|||
} |
|||
|
|||
throw new InternalServerErrorException('An unknown error occurred'); |
|||
} |
|||
|
|||
public async generateAuthenticationOptions(deviceId: string) { |
|||
const device = await this.deviceService.authDevice({ id: deviceId }); |
|||
|
|||
if (!device) { |
|||
throw new Error('Device not found'); |
|||
} |
|||
|
|||
const opts: GenerateAuthenticationOptionsOpts = { |
|||
allowCredentials: [], |
|||
rpID: this.rpID, |
|||
timeout: ms('60 seconds'), |
|||
userVerification: 'preferred' |
|||
}; |
|||
|
|||
const authenticationOptions = await generateAuthenticationOptions(opts); |
|||
|
|||
await this.userService.updateUser({ |
|||
data: { |
|||
authChallenge: authenticationOptions.challenge |
|||
}, |
|||
where: { |
|||
id: device.userId |
|||
} |
|||
}); |
|||
|
|||
return authenticationOptions; |
|||
} |
|||
|
|||
public async verifyAuthentication( |
|||
deviceId: string, |
|||
credential: AssertionCredentialJSON |
|||
) { |
|||
const device = await this.deviceService.authDevice({ id: deviceId }); |
|||
|
|||
if (!device) { |
|||
throw new Error('Device not found'); |
|||
} |
|||
|
|||
const user = await this.userService.user({ id: device.userId }); |
|||
|
|||
let verification: VerifiedAuthenticationResponse; |
|||
|
|||
try { |
|||
const opts: VerifyAuthenticationResponseOpts = { |
|||
credential: { |
|||
counter: device.counter, |
|||
id: isoBase64URL.fromBuffer(device.credentialId), |
|||
publicKey: device.credentialPublicKey |
|||
}, |
|||
expectedChallenge: `${user.authChallenge}`, |
|||
expectedOrigin: this.expectedOrigin, |
|||
expectedRPID: this.rpID, |
|||
requireUserVerification: false, |
|||
response: { |
|||
clientExtensionResults: credential.clientExtensionResults, |
|||
id: credential.id, |
|||
rawId: credential.rawId, |
|||
response: credential.response, |
|||
type: 'public-key' |
|||
} |
|||
}; |
|||
|
|||
verification = await verifyAuthenticationResponse(opts); |
|||
} catch (error) { |
|||
Logger.error(error, 'WebAuthService'); |
|||
throw new InternalServerErrorException({ error: error.message }); |
|||
} |
|||
|
|||
const { authenticationInfo, verified } = verification; |
|||
|
|||
if (verified) { |
|||
device.counter = authenticationInfo.newCounter; |
|||
|
|||
await this.deviceService.updateAuthDevice({ |
|||
data: device, |
|||
where: { id: device.id } |
|||
}); |
|||
|
|||
return this.jwtService.sign({ |
|||
id: user.id |
|||
}); |
|||
} |
|||
|
|||
throw new Error(); |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { Controller, Post, UseGuards } from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
@Controller('cache') |
|||
export class CacheController { |
|||
public constructor(private readonly redisCacheService: RedisCacheService) {} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Post('flush') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async flushCache(): Promise<void> { |
|||
await this.redisCacheService.reset(); |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { CacheController } from './cache.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [CacheController], |
|||
imports: [RedisCacheModule] |
|||
}) |
|||
export class CacheModule {} |
|||
@ -0,0 +1,59 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Controller, |
|||
Get, |
|||
Inject, |
|||
Param, |
|||
Query, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
import { AiService } from './ai.service'; |
|||
|
|||
@Controller('ai') |
|||
export class AiController { |
|||
public constructor( |
|||
private readonly aiService: AiService, |
|||
private readonly apiService: ApiService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get('prompt/:mode') |
|||
@HasPermission(permissions.readAiPrompt) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getPrompt( |
|||
@Param('mode') mode: AiPromptMode, |
|||
@Query('accounts') filterByAccounts?: string, |
|||
@Query('assetClasses') filterByAssetClasses?: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('symbol') filterBySymbol?: string, |
|||
@Query('tags') filterByTags?: string |
|||
): Promise<AiPromptResponse> { |
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAccounts, |
|||
filterByAssetClasses, |
|||
filterByDataSource, |
|||
filterBySymbol, |
|||
filterByTags |
|||
}); |
|||
|
|||
const prompt = await this.aiService.getPrompt({ |
|||
filters, |
|||
mode, |
|||
impersonationId: undefined, |
|||
languageCode: this.request.user.settings.settings.language, |
|||
userCurrency: this.request.user.settings.settings.baseCurrency, |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return { prompt }; |
|||
} |
|||
} |
|||
@ -0,0 +1,59 @@ |
|||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { UserModule } from '@ghostfolio/api/app/user/user.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
|||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AiController } from './ai.controller'; |
|||
import { AiService } from './ai.service'; |
|||
|
|||
@Module({ |
|||
controllers: [AiController], |
|||
imports: [ |
|||
ApiModule, |
|||
BenchmarkModule, |
|||
ConfigurationModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
I18nModule, |
|||
ImpersonationModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
PortfolioSnapshotQueueModule, |
|||
PrismaModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
SymbolProfileModule, |
|||
UserModule |
|||
], |
|||
providers: [ |
|||
AccountBalanceService, |
|||
AccountService, |
|||
AiService, |
|||
CurrentRateService, |
|||
MarketDataService, |
|||
PortfolioCalculatorFactory, |
|||
PortfolioService, |
|||
RulesService |
|||
] |
|||
}) |
|||
export class AiModule {} |
|||
@ -0,0 +1,169 @@ |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { |
|||
PROPERTY_API_KEY_OPENROUTER, |
|||
PROPERTY_OPENROUTER_MODEL |
|||
} from '@ghostfolio/common/config'; |
|||
import { Filter } from '@ghostfolio/common/interfaces'; |
|||
import type { AiPromptMode } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; |
|||
import { generateText } from 'ai'; |
|||
import type { ColumnDescriptor } from 'tablemark'; |
|||
|
|||
@Injectable() |
|||
export class AiService { |
|||
private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ |
|||
key: |
|||
| 'ALLOCATION_PERCENTAGE' |
|||
| 'ASSET_CLASS' |
|||
| 'ASSET_SUB_CLASS' |
|||
| 'CURRENCY' |
|||
| 'NAME' |
|||
| 'SYMBOL'; |
|||
} & ColumnDescriptor)[] = [ |
|||
{ key: 'NAME', name: 'Name' }, |
|||
{ key: 'SYMBOL', name: 'Symbol' }, |
|||
{ key: 'CURRENCY', name: 'Currency' }, |
|||
{ key: 'ASSET_CLASS', name: 'Asset Class' }, |
|||
{ key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' }, |
|||
{ |
|||
align: 'right', |
|||
key: 'ALLOCATION_PERCENTAGE', |
|||
name: 'Allocation in Percentage' |
|||
} |
|||
]; |
|||
|
|||
public constructor( |
|||
private readonly portfolioService: PortfolioService, |
|||
private readonly propertyService: PropertyService |
|||
) {} |
|||
|
|||
public async generateText({ prompt }: { prompt: string }) { |
|||
const openRouterApiKey = await this.propertyService.getByKey<string>( |
|||
PROPERTY_API_KEY_OPENROUTER |
|||
); |
|||
|
|||
const openRouterModel = await this.propertyService.getByKey<string>( |
|||
PROPERTY_OPENROUTER_MODEL |
|||
); |
|||
|
|||
const openRouterService = createOpenRouter({ |
|||
apiKey: openRouterApiKey |
|||
}); |
|||
|
|||
return generateText({ |
|||
prompt, |
|||
model: openRouterService.chat(openRouterModel) |
|||
}); |
|||
} |
|||
|
|||
public async getPrompt({ |
|||
filters, |
|||
impersonationId, |
|||
languageCode, |
|||
mode, |
|||
userCurrency, |
|||
userId |
|||
}: { |
|||
filters?: Filter[]; |
|||
impersonationId: string; |
|||
languageCode: string; |
|||
mode: AiPromptMode; |
|||
userCurrency: string; |
|||
userId: string; |
|||
}) { |
|||
const { holdings } = await this.portfolioService.getDetails({ |
|||
filters, |
|||
impersonationId, |
|||
userId |
|||
}); |
|||
|
|||
const holdingsTableColumns: ColumnDescriptor[] = |
|||
AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ align, name }) => { |
|||
return { name, align: align ?? 'left' }; |
|||
}); |
|||
|
|||
const holdingsTableRows = Object.values(holdings) |
|||
.sort((a, b) => { |
|||
return b.allocationInPercentage - a.allocationInPercentage; |
|||
}) |
|||
.map( |
|||
({ |
|||
allocationInPercentage, |
|||
assetClass, |
|||
assetSubClass, |
|||
currency, |
|||
name: label, |
|||
symbol |
|||
}) => { |
|||
return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce( |
|||
(row, { key, name }) => { |
|||
switch (key) { |
|||
case 'ALLOCATION_PERCENTAGE': |
|||
row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`; |
|||
break; |
|||
|
|||
case 'ASSET_CLASS': |
|||
row[name] = assetClass ?? ''; |
|||
break; |
|||
|
|||
case 'ASSET_SUB_CLASS': |
|||
row[name] = assetSubClass ?? ''; |
|||
break; |
|||
|
|||
case 'CURRENCY': |
|||
row[name] = currency; |
|||
break; |
|||
|
|||
case 'NAME': |
|||
row[name] = label; |
|||
break; |
|||
|
|||
case 'SYMBOL': |
|||
row[name] = symbol; |
|||
break; |
|||
|
|||
default: |
|||
row[name] = ''; |
|||
break; |
|||
} |
|||
|
|||
return row; |
|||
}, |
|||
{} as Record<string, string> |
|||
); |
|||
} |
|||
); |
|||
|
|||
// Dynamic import to load ESM module from CommonJS context
|
|||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|||
const dynamicImport = new Function('s', 'return import(s)') as ( |
|||
s: string |
|||
) => Promise<typeof import('tablemark')>; |
|||
const { tablemark } = await dynamicImport('tablemark'); |
|||
|
|||
const holdingsTableString = tablemark(holdingsTableRows, { |
|||
columns: holdingsTableColumns |
|||
}); |
|||
|
|||
if (mode === 'portfolio') { |
|||
return holdingsTableString; |
|||
} |
|||
|
|||
return [ |
|||
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, |
|||
holdingsTableString, |
|||
'Structure your answer with these sections:', |
|||
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.', |
|||
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.', |
|||
'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.', |
|||
'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.', |
|||
'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).', |
|||
'Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.', |
|||
'Conclusion: Provide a concise summary highlighting key insights.', |
|||
`Provide your answer in the following language: ${languageCode}.` |
|||
].join('\n'); |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; |
|||
import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
@Controller('api-keys') |
|||
export class ApiKeysController { |
|||
public constructor( |
|||
private readonly apiKeyService: ApiKeyService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@HasPermission(permissions.createApiKey) |
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async createApiKey(): Promise<ApiKeyResponse> { |
|||
return this.apiKeyService.create({ userId: this.request.user.id }); |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { ApiKeysController } from './api-keys.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [ApiKeysController], |
|||
imports: [ApiKeyModule] |
|||
}) |
|||
export class ApiKeysModule {} |
|||
@ -0,0 +1,46 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { interpolate } from '@ghostfolio/common/helper'; |
|||
|
|||
import { |
|||
Controller, |
|||
Get, |
|||
Param, |
|||
Res, |
|||
Version, |
|||
VERSION_NEUTRAL |
|||
} from '@nestjs/common'; |
|||
import { Response } from 'express'; |
|||
import { readFileSync } from 'node:fs'; |
|||
import { join } from 'node:path'; |
|||
|
|||
@Controller('assets') |
|||
export class AssetsController { |
|||
private webManifest = ''; |
|||
|
|||
public constructor( |
|||
public readonly configurationService: ConfigurationService |
|||
) { |
|||
try { |
|||
this.webManifest = readFileSync( |
|||
join(__dirname, 'assets', 'site.webmanifest'), |
|||
'utf8' |
|||
); |
|||
} catch {} |
|||
} |
|||
|
|||
@Get('/:languageCode/site.webmanifest') |
|||
@Version(VERSION_NEUTRAL) |
|||
public getWebManifest( |
|||
@Param('languageCode') languageCode: string, |
|||
@Res() response: Response |
|||
): void { |
|||
const rootUrl = this.configurationService.get('ROOT_URL'); |
|||
const webManifest = interpolate(this.webManifest, { |
|||
languageCode, |
|||
rootUrl |
|||
}); |
|||
|
|||
response.setHeader('Content-Type', 'application/json'); |
|||
response.send(webManifest); |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { AssetsController } from './assets.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [AssetsController], |
|||
providers: [ConfigurationService] |
|||
}) |
|||
export class AssetsModule {} |
|||
@ -0,0 +1,156 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; |
|||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
|||
import type { |
|||
AssetProfileIdentifier, |
|||
BenchmarkMarketDataDetailsResponse, |
|||
BenchmarkResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
Headers, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
Query, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { BenchmarksService } from './benchmarks.service'; |
|||
|
|||
@Controller('benchmarks') |
|||
export class BenchmarksController { |
|||
public constructor( |
|||
private readonly apiService: ApiService, |
|||
private readonly benchmarkService: BenchmarkService, |
|||
private readonly benchmarksService: BenchmarksService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@HasPermission(permissions.accessAdminControl) |
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async addBenchmark( |
|||
@Body() { dataSource, symbol }: AssetProfileIdentifier |
|||
) { |
|||
try { |
|||
const benchmark = await this.benchmarkService.addBenchmark({ |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
|
|||
if (!benchmark) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return benchmark; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Delete(':dataSource/:symbol') |
|||
@HasPermission(permissions.accessAdminControl) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteBenchmark( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
) { |
|||
try { |
|||
const benchmark = await this.benchmarkService.deleteBenchmark({ |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
|
|||
if (!benchmark) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return benchmark; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get() |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getBenchmark(): Promise<BenchmarkResponse> { |
|||
return { |
|||
benchmarks: await this.benchmarkService.getBenchmarks() |
|||
}; |
|||
} |
|||
|
|||
@Get(':dataSource/:symbol/:startDateString') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async getBenchmarkMarketDataForUser( |
|||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('startDateString') startDateString: string, |
|||
@Param('symbol') symbol: string, |
|||
@Query('range') dateRange: DateRange = 'max', |
|||
@Query('accounts') filterByAccounts?: string, |
|||
@Query('assetClasses') filterByAssetClasses?: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('symbol') filterBySymbol?: string, |
|||
@Query('tags') filterByTags?: string, |
|||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' |
|||
): Promise<BenchmarkMarketDataDetailsResponse> { |
|||
const { endDate, startDate } = getIntervalFromDateRange( |
|||
dateRange, |
|||
new Date(startDateString) |
|||
); |
|||
|
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAccounts, |
|||
filterByAssetClasses, |
|||
filterByDataSource, |
|||
filterBySymbol, |
|||
filterByTags |
|||
}); |
|||
|
|||
const withExcludedAccounts = withExcludedAccountsParam === 'true'; |
|||
|
|||
return this.benchmarksService.getMarketDataForUser({ |
|||
dataSource, |
|||
dateRange, |
|||
endDate, |
|||
filters, |
|||
impersonationId, |
|||
startDate, |
|||
symbol, |
|||
withExcludedAccounts, |
|||
user: this.request.user |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,65 @@ |
|||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; |
|||
import { UserModule } from '@ghostfolio/api/app/user/user.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
|||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { BenchmarksController } from './benchmarks.controller'; |
|||
import { BenchmarksService } from './benchmarks.service'; |
|||
|
|||
@Module({ |
|||
controllers: [BenchmarksController], |
|||
imports: [ |
|||
ApiModule, |
|||
ConfigurationModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
I18nModule, |
|||
ImpersonationModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
PortfolioSnapshotQueueModule, |
|||
PrismaModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
SymbolModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule, |
|||
UserModule |
|||
], |
|||
providers: [ |
|||
AccountBalanceService, |
|||
AccountService, |
|||
BenchmarkService, |
|||
BenchmarksService, |
|||
CurrentRateService, |
|||
MarketDataService, |
|||
PortfolioCalculatorFactory, |
|||
PortfolioService, |
|||
RulesService |
|||
] |
|||
}) |
|||
export class BenchmarksModule {} |
|||
@ -0,0 +1,163 @@ |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; |
|||
import { |
|||
AssetProfileIdentifier, |
|||
BenchmarkMarketDataDetailsResponse, |
|||
Filter |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { format, isSameDay } from 'date-fns'; |
|||
import { isNumber } from 'lodash'; |
|||
|
|||
@Injectable() |
|||
export class BenchmarksService { |
|||
public constructor( |
|||
private readonly benchmarkService: BenchmarkService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly marketDataService: MarketDataService, |
|||
private readonly portfolioService: PortfolioService, |
|||
private readonly symbolService: SymbolService |
|||
) {} |
|||
|
|||
public async getMarketDataForUser({ |
|||
dataSource, |
|||
dateRange, |
|||
endDate = new Date(), |
|||
filters, |
|||
impersonationId, |
|||
startDate, |
|||
symbol, |
|||
user, |
|||
withExcludedAccounts |
|||
}: { |
|||
dateRange: DateRange; |
|||
endDate?: Date; |
|||
filters?: Filter[]; |
|||
impersonationId: string; |
|||
startDate: Date; |
|||
user: UserWithSettings; |
|||
withExcludedAccounts?: boolean; |
|||
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> { |
|||
const marketData: { date: string; value: number }[] = []; |
|||
const userCurrency = user.settings.settings.baseCurrency; |
|||
const userId = user.id; |
|||
|
|||
const { chart } = await this.portfolioService.getPerformance({ |
|||
dateRange, |
|||
filters, |
|||
impersonationId, |
|||
userId, |
|||
withExcludedAccounts |
|||
}); |
|||
|
|||
const [currentSymbolItem, marketDataItems] = await Promise.all([ |
|||
this.symbolService.get({ |
|||
dataGatheringItem: { |
|||
dataSource, |
|||
symbol |
|||
} |
|||
}), |
|||
this.marketDataService.marketDataItems({ |
|||
orderBy: { |
|||
date: 'asc' |
|||
}, |
|||
where: { |
|||
dataSource, |
|||
symbol, |
|||
date: { |
|||
in: chart.map(({ date }) => { |
|||
return resetHours(parseDate(date)); |
|||
}) |
|||
} |
|||
} |
|||
}) |
|||
]); |
|||
|
|||
const exchangeRates = |
|||
await this.exchangeRateDataService.getExchangeRatesByCurrency({ |
|||
startDate, |
|||
currencies: [currentSymbolItem.currency], |
|||
targetCurrency: userCurrency |
|||
}); |
|||
|
|||
const exchangeRateAtStartDate = |
|||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ |
|||
format(startDate, DATE_FORMAT) |
|||
]; |
|||
|
|||
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { |
|||
return isSameDay(date, startDate); |
|||
})?.marketPrice; |
|||
|
|||
if (!marketPriceAtStartDate) { |
|||
Logger.error( |
|||
`No historical market data has been found for ${symbol} (${dataSource}) at ${format( |
|||
startDate, |
|||
DATE_FORMAT |
|||
)}`,
|
|||
'BenchmarkService' |
|||
); |
|||
|
|||
return { marketData }; |
|||
} |
|||
|
|||
for (const marketDataItem of marketDataItems) { |
|||
const exchangeRate = |
|||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ |
|||
format(marketDataItem.date, DATE_FORMAT) |
|||
]; |
|||
|
|||
const exchangeRateFactor = |
|||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) |
|||
? exchangeRate / exchangeRateAtStartDate |
|||
: 1; |
|||
|
|||
marketData.push({ |
|||
date: format(marketDataItem.date, DATE_FORMAT), |
|||
value: |
|||
marketPriceAtStartDate === 0 |
|||
? 0 |
|||
: this.benchmarkService.calculateChangeInPercentage( |
|||
marketPriceAtStartDate, |
|||
marketDataItem.marketPrice * exchangeRateFactor |
|||
) * 100 |
|||
}); |
|||
} |
|||
|
|||
const includesEndDate = isSameDay( |
|||
parseDate(marketData.at(-1).date), |
|||
endDate |
|||
); |
|||
|
|||
if (currentSymbolItem?.marketPrice && !includesEndDate) { |
|||
const exchangeRate = |
|||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ |
|||
format(endDate, DATE_FORMAT) |
|||
]; |
|||
|
|||
const exchangeRateFactor = |
|||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) |
|||
? exchangeRate / exchangeRateAtStartDate |
|||
: 1; |
|||
|
|||
marketData.push({ |
|||
date: format(endDate, DATE_FORMAT), |
|||
value: |
|||
this.benchmarkService.calculateChangeInPercentage( |
|||
marketPriceAtStartDate, |
|||
currentSymbolItem.marketPrice * exchangeRateFactor |
|||
) * 100 |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
marketData |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
import { Granularity } from '@ghostfolio/common/types'; |
|||
|
|||
import { IsIn, IsISO8601, IsOptional } from 'class-validator'; |
|||
|
|||
export class GetDividendsDto { |
|||
@IsISO8601() |
|||
from: string; |
|||
|
|||
@IsIn(['day', 'month'] as Granularity[]) |
|||
@IsOptional() |
|||
granularity: Granularity; |
|||
|
|||
@IsISO8601() |
|||
to: string; |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
import { Granularity } from '@ghostfolio/common/types'; |
|||
|
|||
import { IsIn, IsISO8601, IsOptional } from 'class-validator'; |
|||
|
|||
export class GetHistoricalDto { |
|||
@IsISO8601() |
|||
from: string; |
|||
|
|||
@IsIn(['day', 'month'] as Granularity[]) |
|||
@IsOptional() |
|||
granularity: Granularity; |
|||
|
|||
@IsISO8601() |
|||
to: string; |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import { Transform } from 'class-transformer'; |
|||
import { IsString } from 'class-validator'; |
|||
|
|||
export class GetQuotesDto { |
|||
@IsString({ each: true }) |
|||
@Transform(({ value }) => |
|||
typeof value === 'string' ? value.split(',') : value |
|||
) |
|||
symbols: string[]; |
|||
} |
|||
@ -0,0 +1,249 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { AssetProfileInvalidError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-invalid.error'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { |
|||
DataProviderGhostfolioAssetProfileResponse, |
|||
DataProviderGhostfolioStatusResponse, |
|||
DividendsResponse, |
|||
HistoricalResponse, |
|||
LookupResponse, |
|||
QuotesResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Query, |
|||
UseGuards, |
|||
Version |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { isISIN } from 'class-validator'; |
|||
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
|||
|
|||
import { GetDividendsDto } from './get-dividends.dto'; |
|||
import { GetHistoricalDto } from './get-historical.dto'; |
|||
import { GetQuotesDto } from './get-quotes.dto'; |
|||
import { GhostfolioService } from './ghostfolio.service'; |
|||
|
|||
@Controller('data-providers/ghostfolio') |
|||
export class GhostfolioController { |
|||
public constructor( |
|||
private readonly ghostfolioService: GhostfolioService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get('asset-profile/:symbol') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
|||
public async getAssetProfile( |
|||
@Param('symbol') symbol: string |
|||
): Promise<DataProviderGhostfolioAssetProfileResponse> { |
|||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
|||
|
|||
if ( |
|||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const assetProfile = await this.ghostfolioService.getAssetProfile({ |
|||
symbol |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return assetProfile; |
|||
} catch (error) { |
|||
if (error instanceof AssetProfileInvalidError) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('dividends/:symbol') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
|||
@Version('2') |
|||
public async getDividends( |
|||
@Param('symbol') symbol: string, |
|||
@Query() query: GetDividendsDto |
|||
): Promise<DividendsResponse> { |
|||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
|||
|
|||
if ( |
|||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const dividends = await this.ghostfolioService.getDividends({ |
|||
symbol, |
|||
from: parseDate(query.from), |
|||
granularity: query.granularity, |
|||
to: parseDate(query.to) |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return dividends; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('historical/:symbol') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
|||
@Version('2') |
|||
public async getHistorical( |
|||
@Param('symbol') symbol: string, |
|||
@Query() query: GetHistoricalDto |
|||
): Promise<HistoricalResponse> { |
|||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
|||
|
|||
if ( |
|||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const historicalData = await this.ghostfolioService.getHistorical({ |
|||
symbol, |
|||
from: parseDate(query.from), |
|||
granularity: query.granularity, |
|||
to: parseDate(query.to) |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return historicalData; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('lookup') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
|||
@Version('2') |
|||
public async lookupSymbol( |
|||
@Query('includeIndices') includeIndicesParam = 'false', |
|||
@Query('query') query = '' |
|||
): Promise<LookupResponse> { |
|||
const includeIndices = includeIndicesParam === 'true'; |
|||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
|||
|
|||
if ( |
|||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const result = await this.ghostfolioService.lookup({ |
|||
includeIndices, |
|||
query: isISIN(query.toUpperCase()) |
|||
? query.toUpperCase() |
|||
: query.toLowerCase() |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return result; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('quotes') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
|||
@Version('2') |
|||
public async getQuotes( |
|||
@Query() query: GetQuotesDto |
|||
): Promise<QuotesResponse> { |
|||
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); |
|||
|
|||
if ( |
|||
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), |
|||
StatusCodes.TOO_MANY_REQUESTS |
|||
); |
|||
} |
|||
|
|||
try { |
|||
const quotes = await this.ghostfolioService.getQuotes({ |
|||
symbols: query.symbols |
|||
}); |
|||
|
|||
await this.ghostfolioService.incrementDailyRequests({ |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return quotes; |
|||
} catch { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), |
|||
StatusCodes.INTERNAL_SERVER_ERROR |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('status') |
|||
@HasPermission(permissions.enableDataProviderGhostfolio) |
|||
@UseGuards(AuthGuard('api-key'), HasPermissionGuard) |
|||
@Version('2') |
|||
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> { |
|||
return this.ghostfolioService.getStatus({ user: this.request.user }); |
|||
} |
|||
} |
|||
@ -0,0 +1,83 @@ |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; |
|||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; |
|||
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; |
|||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; |
|||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; |
|||
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; |
|||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; |
|||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; |
|||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; |
|||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { GhostfolioController } from './ghostfolio.controller'; |
|||
import { GhostfolioService } from './ghostfolio.service'; |
|||
|
|||
@Module({ |
|||
controllers: [GhostfolioController], |
|||
imports: [ |
|||
CryptocurrencyModule, |
|||
DataProviderModule, |
|||
MarketDataModule, |
|||
PrismaModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
SymbolProfileModule |
|||
], |
|||
providers: [ |
|||
AlphaVantageService, |
|||
CoinGeckoService, |
|||
ConfigurationService, |
|||
DataProviderService, |
|||
EodHistoricalDataService, |
|||
FinancialModelingPrepService, |
|||
GhostfolioService, |
|||
GoogleSheetsService, |
|||
ManualService, |
|||
RapidApiService, |
|||
YahooFinanceService, |
|||
YahooFinanceDataEnhancerService, |
|||
{ |
|||
inject: [ |
|||
AlphaVantageService, |
|||
CoinGeckoService, |
|||
EodHistoricalDataService, |
|||
FinancialModelingPrepService, |
|||
GoogleSheetsService, |
|||
ManualService, |
|||
RapidApiService, |
|||
YahooFinanceService |
|||
], |
|||
provide: 'DataProviderInterfaces', |
|||
useFactory: ( |
|||
alphaVantageService, |
|||
coinGeckoService, |
|||
eodHistoricalDataService, |
|||
financialModelingPrepService, |
|||
googleSheetsService, |
|||
manualService, |
|||
rapidApiService, |
|||
yahooFinanceService |
|||
) => [ |
|||
alphaVantageService, |
|||
coinGeckoService, |
|||
eodHistoricalDataService, |
|||
financialModelingPrepService, |
|||
googleSheetsService, |
|||
manualService, |
|||
rapidApiService, |
|||
yahooFinanceService |
|||
] |
|||
} |
|||
] |
|||
}) |
|||
export class GhostfolioModule {} |
|||
@ -0,0 +1,375 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; |
|||
import { |
|||
GetAssetProfileParams, |
|||
GetDividendsParams, |
|||
GetHistoricalParams, |
|||
GetQuotesParams, |
|||
GetSearchParams |
|||
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { |
|||
DEFAULT_CURRENCY, |
|||
DERIVED_CURRENCIES |
|||
} from '@ghostfolio/common/config'; |
|||
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; |
|||
import { |
|||
DataProviderGhostfolioAssetProfileResponse, |
|||
DataProviderHistoricalResponse, |
|||
DataProviderInfo, |
|||
DividendsResponse, |
|||
HistoricalResponse, |
|||
LookupItem, |
|||
LookupResponse, |
|||
QuotesResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { UserWithSettings } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { DataSource, SymbolProfile } from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
|
|||
@Injectable() |
|||
export class GhostfolioService { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly propertyService: PropertyService |
|||
) {} |
|||
|
|||
public async getAssetProfile({ symbol }: GetAssetProfileParams) { |
|||
let result: DataProviderGhostfolioAssetProfileResponse = {}; |
|||
|
|||
try { |
|||
const promises: Promise<Partial<SymbolProfile>>[] = []; |
|||
|
|||
for (const dataProviderService of this.getDataProviderServices()) { |
|||
promises.push( |
|||
this.dataProviderService |
|||
.getAssetProfiles([ |
|||
{ |
|||
symbol, |
|||
dataSource: dataProviderService.getName() |
|||
} |
|||
]) |
|||
.then(async (assetProfiles) => { |
|||
const assetProfile = assetProfiles[symbol]; |
|||
const dataSourceOrigin = DataSource.GHOSTFOLIO; |
|||
|
|||
if (assetProfile) { |
|||
await this.prismaService.assetProfileResolution.upsert({ |
|||
create: { |
|||
dataSourceOrigin, |
|||
currency: assetProfile.currency, |
|||
dataSourceTarget: assetProfile.dataSource, |
|||
symbolOrigin: symbol, |
|||
symbolTarget: assetProfile.symbol |
|||
}, |
|||
update: { |
|||
requestCount: { |
|||
increment: 1 |
|||
} |
|||
}, |
|||
where: { |
|||
dataSourceOrigin_symbolOrigin: { |
|||
dataSourceOrigin, |
|||
symbolOrigin: symbol |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
result = { |
|||
...result, |
|||
...assetProfile, |
|||
dataSource: dataSourceOrigin |
|||
}; |
|||
|
|||
return assetProfile; |
|||
}) |
|||
); |
|||
} |
|||
|
|||
await Promise.all(promises); |
|||
|
|||
return result; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
public async getDividends({ |
|||
from, |
|||
granularity, |
|||
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), |
|||
symbol, |
|||
to |
|||
}: GetDividendsParams) { |
|||
const result: DividendsResponse = { dividends: {} }; |
|||
|
|||
try { |
|||
const promises: Promise<{ |
|||
[date: string]: DataProviderHistoricalResponse; |
|||
}>[] = []; |
|||
|
|||
for (const dataProviderService of this.getDataProviderServices()) { |
|||
promises.push( |
|||
dataProviderService |
|||
.getDividends({ |
|||
from, |
|||
granularity, |
|||
requestTimeout, |
|||
symbol, |
|||
to |
|||
}) |
|||
.then((dividends) => { |
|||
result.dividends = dividends; |
|||
|
|||
return dividends; |
|||
}) |
|||
); |
|||
} |
|||
|
|||
await Promise.all(promises); |
|||
|
|||
return result; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
public async getHistorical({ |
|||
from, |
|||
granularity, |
|||
requestTimeout, |
|||
to, |
|||
symbol |
|||
}: GetHistoricalParams) { |
|||
const result: HistoricalResponse = { historicalData: {} }; |
|||
|
|||
try { |
|||
const promises: Promise<{ |
|||
[symbol: string]: { [date: string]: DataProviderHistoricalResponse }; |
|||
}>[] = []; |
|||
|
|||
for (const dataProviderService of this.getDataProviderServices()) { |
|||
promises.push( |
|||
dataProviderService |
|||
.getHistorical({ |
|||
from, |
|||
granularity, |
|||
requestTimeout, |
|||
symbol, |
|||
to |
|||
}) |
|||
.then((historicalData) => { |
|||
result.historicalData = historicalData[symbol]; |
|||
|
|||
return historicalData; |
|||
}) |
|||
); |
|||
} |
|||
|
|||
await Promise.all(promises); |
|||
|
|||
return result; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
public async getMaxDailyRequests() { |
|||
return parseInt( |
|||
(await this.propertyService.getByKey<string>( |
|||
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS |
|||
)) || '0', |
|||
10 |
|||
); |
|||
} |
|||
|
|||
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) { |
|||
const results: QuotesResponse = { quotes: {} }; |
|||
|
|||
try { |
|||
const promises: Promise<any>[] = []; |
|||
|
|||
for (const dataProvider of this.getDataProviderServices()) { |
|||
const maximumNumberOfSymbolsPerRequest = |
|||
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? |
|||
Number.MAX_SAFE_INTEGER; |
|||
|
|||
for ( |
|||
let i = 0; |
|||
i < symbols.length; |
|||
i += maximumNumberOfSymbolsPerRequest |
|||
) { |
|||
const symbolsChunk = symbols.slice( |
|||
i, |
|||
i + maximumNumberOfSymbolsPerRequest |
|||
); |
|||
|
|||
const promise = Promise.resolve( |
|||
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk }) |
|||
); |
|||
|
|||
promises.push( |
|||
promise.then(async (result) => { |
|||
for (const [symbol, dataProviderResponse] of Object.entries( |
|||
result |
|||
)) { |
|||
dataProviderResponse.dataSource = 'GHOSTFOLIO'; |
|||
|
|||
if ( |
|||
[ |
|||
...DERIVED_CURRENCIES.map(({ currency }) => { |
|||
return `${DEFAULT_CURRENCY}${currency}`; |
|||
}), |
|||
`${DEFAULT_CURRENCY}USX` |
|||
].includes(symbol) |
|||
) { |
|||
continue; |
|||
} |
|||
|
|||
results.quotes[symbol] = dataProviderResponse; |
|||
|
|||
for (const { |
|||
currency, |
|||
factor, |
|||
rootCurrency |
|||
} of DERIVED_CURRENCIES) { |
|||
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) { |
|||
results.quotes[`${DEFAULT_CURRENCY}${currency}`] = { |
|||
...dataProviderResponse, |
|||
currency, |
|||
marketPrice: new Big( |
|||
result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice |
|||
) |
|||
.mul(factor) |
|||
.toNumber(), |
|||
marketState: 'open' |
|||
}; |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
); |
|||
} |
|||
|
|||
await Promise.all(promises); |
|||
} |
|||
|
|||
return results; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
public async getStatus({ user }: { user: UserWithSettings }) { |
|||
return { |
|||
dailyRequests: user.dataProviderGhostfolioDailyRequests, |
|||
dailyRequestsMax: await this.getMaxDailyRequests(), |
|||
subscription: user.subscription |
|||
}; |
|||
} |
|||
|
|||
public async incrementDailyRequests({ userId }: { userId: string }) { |
|||
await this.prismaService.analytics.update({ |
|||
data: { |
|||
dataProviderGhostfolioDailyRequests: { increment: 1 } |
|||
}, |
|||
where: { userId } |
|||
}); |
|||
} |
|||
|
|||
public async lookup({ |
|||
includeIndices = false, |
|||
query |
|||
}: GetSearchParams): Promise<LookupResponse> { |
|||
const results: LookupResponse = { items: [] }; |
|||
|
|||
if (!query) { |
|||
return results; |
|||
} |
|||
|
|||
try { |
|||
let lookupItems: LookupItem[] = []; |
|||
const promises: Promise<{ items: LookupItem[] }>[] = []; |
|||
|
|||
if (query?.length < 2) { |
|||
return { items: lookupItems }; |
|||
} |
|||
|
|||
for (const dataProviderService of this.getDataProviderServices()) { |
|||
promises.push( |
|||
dataProviderService.search({ |
|||
includeIndices, |
|||
query |
|||
}) |
|||
); |
|||
} |
|||
|
|||
const searchResults = await Promise.all(promises); |
|||
|
|||
for (const { items } of searchResults) { |
|||
if (items?.length > 0) { |
|||
lookupItems = lookupItems.concat(items); |
|||
} |
|||
} |
|||
|
|||
const filteredItems = lookupItems |
|||
.filter(({ currency }) => { |
|||
// Only allow symbols with supported currency
|
|||
return currency ? true : false; |
|||
}) |
|||
.sort(({ name: name1 }, { name: name2 }) => { |
|||
return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); |
|||
}) |
|||
.map((lookupItem) => { |
|||
lookupItem.dataProviderInfo = this.getDataProviderInfo(); |
|||
lookupItem.dataSource = 'GHOSTFOLIO'; |
|||
|
|||
return lookupItem; |
|||
}); |
|||
|
|||
results.items = filteredItems; |
|||
|
|||
return results; |
|||
} catch (error) { |
|||
Logger.error(error, 'GhostfolioService'); |
|||
|
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
private getDataProviderInfo(): DataProviderInfo { |
|||
const ghostfolioDataProviderService = new GhostfolioDataProviderService( |
|||
this.configurationService, |
|||
this.propertyService |
|||
); |
|||
|
|||
return { |
|||
...ghostfolioDataProviderService.getDataProviderInfo(), |
|||
isPremium: false, |
|||
name: 'Ghostfolio Premium' |
|||
}; |
|||
} |
|||
|
|||
private getDataProviderServices() { |
|||
return this.configurationService |
|||
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') |
|||
.map((dataSource) => { |
|||
return this.dataProviderService.getDataProvider(DataSource[dataSource]); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,193 @@ |
|||
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; |
|||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { |
|||
ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, |
|||
ghostfolioFearAndGreedIndexDataSourceStocks, |
|||
ghostfolioFearAndGreedIndexSymbolCryptocurrencies, |
|||
ghostfolioFearAndGreedIndexSymbolStocks |
|||
} from '@ghostfolio/common/config'; |
|||
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos'; |
|||
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; |
|||
import { |
|||
MarketDataDetailsResponse, |
|||
MarketDataOfMarketsResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
Query, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { DataSource, Prisma } from '@prisma/client'; |
|||
import { parseISO } from 'date-fns'; |
|||
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; |
|||
|
|||
@Controller('market-data') |
|||
export class MarketDataController { |
|||
public constructor( |
|||
private readonly adminService: AdminService, |
|||
private readonly marketDataService: MarketDataService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser, |
|||
private readonly symbolProfileService: SymbolProfileService, |
|||
private readonly symbolService: SymbolService |
|||
) {} |
|||
|
|||
@Get('markets') |
|||
@HasPermission(permissions.readMarketDataOfMarkets) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getMarketDataOfMarkets( |
|||
@Query('includeHistoricalData') includeHistoricalData = 0 |
|||
): Promise<MarketDataOfMarketsResponse> { |
|||
const [ |
|||
marketDataFearAndGreedIndexCryptocurrencies, |
|||
marketDataFearAndGreedIndexStocks |
|||
] = await Promise.all([ |
|||
this.symbolService.get({ |
|||
includeHistoricalData, |
|||
dataGatheringItem: { |
|||
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, |
|||
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies |
|||
} |
|||
}), |
|||
this.symbolService.get({ |
|||
includeHistoricalData, |
|||
dataGatheringItem: { |
|||
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, |
|||
symbol: ghostfolioFearAndGreedIndexSymbolStocks |
|||
} |
|||
}) |
|||
]); |
|||
|
|||
return { |
|||
fearAndGreedIndex: { |
|||
CRYPTOCURRENCIES: { |
|||
...marketDataFearAndGreedIndexCryptocurrencies |
|||
}, |
|||
STOCKS: { |
|||
...marketDataFearAndGreedIndexStocks |
|||
} |
|||
} |
|||
}; |
|||
} |
|||
|
|||
@Get(':dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getMarketDataBySymbol( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<MarketDataDetailsResponse> { |
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const canReadAllAssetProfiles = hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.readMarketData |
|||
); |
|||
|
|||
const canReadOwnAssetProfile = |
|||
assetProfile?.userId === this.request.user.id && |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.readMarketDataOfOwnAssetProfile |
|||
); |
|||
|
|||
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { |
|||
throw new HttpException( |
|||
assetProfile.userId |
|||
? getReasonPhrase(StatusCodes.NOT_FOUND) |
|||
: getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); |
|||
} |
|||
|
|||
@Post(':dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async updateMarketData( |
|||
@Body() data: UpdateBulkMarketDataDto, |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
) { |
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const canUpsertAllAssetProfiles = |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.createMarketData |
|||
) && |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.updateMarketData |
|||
); |
|||
|
|||
const canUpsertOwnAssetProfile = |
|||
assetProfile?.userId === this.request.user.id && |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.createMarketDataOfOwnAssetProfile |
|||
) && |
|||
hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.updateMarketDataOfOwnAssetProfile |
|||
); |
|||
|
|||
if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( |
|||
({ date, marketPrice }) => ({ |
|||
dataSource, |
|||
marketPrice, |
|||
symbol, |
|||
date: parseISO(date), |
|||
state: 'CLOSE' |
|||
}) |
|||
); |
|||
|
|||
return this.marketDataService.updateMany({ |
|||
data: dataBulkUpdate |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; |
|||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { MarketDataController } from './market-data.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [MarketDataController], |
|||
imports: [ |
|||
AdminModule, |
|||
MarketDataServiceModule, |
|||
SymbolModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule |
|||
] |
|||
}) |
|||
export class MarketDataModule {} |
|||
@ -0,0 +1,24 @@ |
|||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { PlatformsResponse } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { Controller, Get, UseGuards } from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
@Controller('platforms') |
|||
export class PlatformsController { |
|||
public constructor(private readonly platformService: PlatformService) {} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.readPlatforms) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getPlatforms(): Promise<PlatformsResponse> { |
|||
const platforms = await this.platformService.getPlatforms({ |
|||
orderBy: { name: 'asc' } |
|||
}); |
|||
|
|||
return { platforms }; |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { PlatformsController } from './platforms.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [PlatformsController], |
|||
imports: [PlatformModule] |
|||
}) |
|||
export class PlatformsModule {} |
|||
@ -0,0 +1,187 @@ |
|||
import { AccessService } from '@ghostfolio/api/app/access/access.service'; |
|||
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
|||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; |
|||
import { getSum } from '@ghostfolio/common/helper'; |
|||
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { Type as ActivityType } from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
@Controller('public') |
|||
export class PublicController { |
|||
public constructor( |
|||
private readonly accessService: AccessService, |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly orderService: OrderService, |
|||
private readonly portfolioService: PortfolioService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser, |
|||
private readonly userService: UserService |
|||
) {} |
|||
|
|||
@Get(':accessId/portfolio') |
|||
@UseInterceptors(RedactValuesInResponseInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getPublicPortfolio( |
|||
@Param('accessId') accessId: string |
|||
): Promise<PublicPortfolioResponse> { |
|||
const access = await this.accessService.access({ id: accessId }); |
|||
|
|||
if (!access) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
let hasDetails = true; |
|||
|
|||
const user = await this.userService.user({ |
|||
id: access.userId |
|||
}); |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
|||
hasDetails = user.subscription.type === 'Premium'; |
|||
} |
|||
|
|||
const [ |
|||
{ createdAt, holdings, markets }, |
|||
{ performance: performance1d }, |
|||
{ performance: performanceMax }, |
|||
{ performance: performanceYtd } |
|||
] = await Promise.all([ |
|||
this.portfolioService.getDetails({ |
|||
impersonationId: access.userId, |
|||
userId: user.id, |
|||
withMarkets: true |
|||
}), |
|||
...['1d', 'max', 'ytd'].map((dateRange) => { |
|||
return this.portfolioService.getPerformance({ |
|||
dateRange, |
|||
impersonationId: undefined, |
|||
userId: user.id |
|||
}); |
|||
}) |
|||
]); |
|||
|
|||
const { activities } = await this.orderService.getOrders({ |
|||
sortColumn: 'date', |
|||
sortDirection: 'desc', |
|||
take: 10, |
|||
types: [ActivityType.BUY, ActivityType.SELL], |
|||
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, |
|||
userId: user.id, |
|||
withExcludedAccountsAndActivities: false |
|||
}); |
|||
|
|||
// Experimental
|
|||
const latestActivities = this.configurationService.get( |
|||
'ENABLE_FEATURE_SUBSCRIPTION' |
|||
) |
|||
? [] |
|||
: activities.map( |
|||
({ |
|||
currency, |
|||
date, |
|||
fee, |
|||
quantity, |
|||
SymbolProfile, |
|||
type, |
|||
unitPrice, |
|||
value, |
|||
valueInBaseCurrency |
|||
}) => { |
|||
return { |
|||
currency, |
|||
date, |
|||
fee, |
|||
quantity, |
|||
SymbolProfile, |
|||
type, |
|||
unitPrice, |
|||
value, |
|||
valueInBaseCurrency |
|||
}; |
|||
} |
|||
); |
|||
|
|||
Object.values(markets ?? {}).forEach((market) => { |
|||
delete market.valueInBaseCurrency; |
|||
}); |
|||
|
|||
const publicPortfolioResponse: PublicPortfolioResponse = { |
|||
createdAt, |
|||
hasDetails, |
|||
latestActivities, |
|||
markets, |
|||
alias: access.alias, |
|||
holdings: {}, |
|||
performance: { |
|||
'1d': { |
|||
relativeChange: |
|||
performance1d.netPerformancePercentageWithCurrencyEffect |
|||
}, |
|||
max: { |
|||
relativeChange: |
|||
performanceMax.netPerformancePercentageWithCurrencyEffect |
|||
}, |
|||
ytd: { |
|||
relativeChange: |
|||
performanceYtd.netPerformancePercentageWithCurrencyEffect |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const totalValue = getSum( |
|||
Object.values(holdings).map(({ currency, marketPrice, quantity }) => { |
|||
return new Big( |
|||
this.exchangeRateDataService.toCurrency( |
|||
quantity * marketPrice, |
|||
currency, |
|||
this.request.user?.settings?.settings.baseCurrency ?? |
|||
DEFAULT_CURRENCY |
|||
) |
|||
); |
|||
}) |
|||
).toNumber(); |
|||
|
|||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { |
|||
publicPortfolioResponse.holdings[symbol] = { |
|||
allocationInPercentage: |
|||
portfolioPosition.valueInBaseCurrency / totalValue, |
|||
assetClass: hasDetails ? portfolioPosition.assetClass : undefined, |
|||
countries: hasDetails ? portfolioPosition.countries : [], |
|||
currency: hasDetails ? portfolioPosition.currency : undefined, |
|||
dataSource: portfolioPosition.dataSource, |
|||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, |
|||
markets: hasDetails ? portfolioPosition.markets : undefined, |
|||
name: portfolioPosition.name, |
|||
netPerformancePercentWithCurrencyEffect: |
|||
portfolioPosition.netPerformancePercentWithCurrencyEffect, |
|||
sectors: hasDetails ? portfolioPosition.sectors : [], |
|||
symbol: portfolioPosition.symbol, |
|||
url: portfolioPosition.url, |
|||
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue |
|||
}; |
|||
} |
|||
|
|||
return publicPortfolioResponse; |
|||
} |
|||
} |
|||
@ -0,0 +1,53 @@ |
|||
import { AccessModule } from '@ghostfolio/api/app/access/access.module'; |
|||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { UserModule } from '@ghostfolio/api/app/user/user.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
|||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { PublicController } from './public.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [PublicController], |
|||
imports: [ |
|||
AccessModule, |
|||
BenchmarkModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
I18nModule, |
|||
ImpersonationModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
PortfolioSnapshotQueueModule, |
|||
PrismaModule, |
|||
RedisCacheModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInRequestModule, |
|||
UserModule |
|||
], |
|||
providers: [ |
|||
AccountBalanceService, |
|||
AccountService, |
|||
CurrentRateService, |
|||
PortfolioCalculatorFactory, |
|||
PortfolioService, |
|||
RulesService |
|||
] |
|||
}) |
|||
export class PublicModule {} |
|||
@ -0,0 +1,52 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
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'; |
|||
import { readFileSync } from 'node:fs'; |
|||
import { join } from 'node:path'; |
|||
|
|||
import { SitemapService } from './sitemap.service'; |
|||
|
|||
@Controller('sitemap.xml') |
|||
export class SitemapController { |
|||
public sitemapXml = ''; |
|||
|
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly sitemapService: SitemapService |
|||
) { |
|||
try { |
|||
this.sitemapXml = readFileSync( |
|||
join(__dirname, 'assets', 'sitemap.xml'), |
|||
'utf8' |
|||
); |
|||
} catch {} |
|||
} |
|||
|
|||
@Get() |
|||
@Version(VERSION_NEUTRAL) |
|||
public getSitemapXml(@Res() response: Response) { |
|||
const currentDate = format(getYesterday(), DATE_FORMAT); |
|||
|
|||
response.setHeader('content-type', 'application/xml'); |
|||
response.send( |
|||
interpolate(this.sitemapXml, { |
|||
blogPosts: this.sitemapService.getBlogPosts({ currentDate }), |
|||
personalFinanceTools: this.configurationService.get( |
|||
'ENABLE_FEATURE_SUBSCRIPTION' |
|||
) |
|||
? this.sitemapService.getPersonalFinanceTools({ currentDate }) |
|||
: '', |
|||
publicRoutes: this.sitemapService.getPublicRoutes({ |
|||
currentDate |
|||
}) |
|||
}) |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; |
|||
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { SitemapController } from './sitemap.controller'; |
|||
import { SitemapService } from './sitemap.service'; |
|||
|
|||
@Module({ |
|||
controllers: [SitemapController], |
|||
imports: [ConfigurationModule, I18nModule], |
|||
providers: [SitemapService] |
|||
}) |
|||
export class SitemapModule {} |
|||
@ -0,0 +1,256 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; |
|||
import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config'; |
|||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; |
|||
import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface'; |
|||
import { publicRoutes } from '@ghostfolio/common/routes/routes'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
|
|||
@Injectable() |
|||
export class SitemapService { |
|||
private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX = |
|||
/:.*@@(?<id>[a-zA-Z0-9.]+):(?<message>.+)/; |
|||
|
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly i18nService: I18nService |
|||
) {} |
|||
|
|||
public getBlogPosts({ currentDate }: { currentDate: string }) { |
|||
const rootUrl = this.configurationService.get('ROOT_URL'); |
|||
|
|||
return [ |
|||
{ |
|||
languageCode: 'de', |
|||
routerLink: ['2021', '07', 'hallo-ghostfolio'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2021', '07', 'hello-ghostfolio'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '01', 'ghostfolio-first-months-in-open-source'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '07', 'ghostfolio-meets-internet-identity'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '07', 'how-do-i-get-my-finances-in-order'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '08', '500-stars-on-github'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '10', 'hacktoberfest-2022'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2022', '11', 'black-friday-2022'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: [ |
|||
'2022', |
|||
'12', |
|||
'the-importance-of-tracking-your-personal-finances' |
|||
] |
|||
}, |
|||
{ |
|||
languageCode: 'de', |
|||
routerLink: ['2023', '01', 'ghostfolio-auf-sackgeld-vorgestellt'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '02', 'ghostfolio-meets-umbrel'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '03', 'ghostfolio-reaches-1000-stars-on-github'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: [ |
|||
'2023', |
|||
'05', |
|||
'unlock-your-financial-potential-with-ghostfolio' |
|||
] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '07', 'exploring-the-path-to-fire'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '08', 'ghostfolio-joins-oss-friends'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '09', 'ghostfolio-2'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '09', 'hacktoberfest-2023'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '11', 'black-week-2023'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2023', '11', 'hacktoberfest-2023-debriefing'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2024', '09', 'hacktoberfest-2024'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2024', '11', 'black-weeks-2024'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2025', '09', 'hacktoberfest-2025'] |
|||
}, |
|||
{ |
|||
languageCode: 'en', |
|||
routerLink: ['2025', '11', 'black-weeks-2025'] |
|||
} |
|||
] |
|||
.map(({ languageCode, routerLink }) => { |
|||
return this.createRouteSitemapUrl({ |
|||
currentDate, |
|||
languageCode, |
|||
rootUrl, |
|||
route: { |
|||
routerLink: [publicRoutes.blog.path, ...routerLink], |
|||
path: undefined |
|||
} |
|||
}); |
|||
}) |
|||
.join('\n'); |
|||
} |
|||
|
|||
public getPersonalFinanceTools({ currentDate }: { currentDate: string }) { |
|||
const rootUrl = this.configurationService.get('ROOT_URL'); |
|||
|
|||
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { |
|||
const resourcesPath = this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: publicRoutes.resources.path.match( |
|||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX |
|||
).groups.id |
|||
}); |
|||
|
|||
const personalFinanceToolsPath = this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: publicRoutes.resources.subRoutes.personalFinanceTools.path.match( |
|||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX |
|||
).groups.id |
|||
}); |
|||
|
|||
const productPath = this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.path.match( |
|||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX |
|||
).groups.id |
|||
}); |
|||
|
|||
return personalFinanceTools.map(({ alias, key }) => { |
|||
const routerLink = [ |
|||
resourcesPath, |
|||
personalFinanceToolsPath, |
|||
`${productPath}-${alias ?? key}` |
|||
]; |
|||
|
|||
return this.createRouteSitemapUrl({ |
|||
currentDate, |
|||
languageCode, |
|||
rootUrl, |
|||
route: { |
|||
routerLink, |
|||
path: undefined |
|||
} |
|||
}); |
|||
}); |
|||
}).join('\n'); |
|||
} |
|||
|
|||
public getPublicRoutes({ currentDate }: { currentDate: string }) { |
|||
const rootUrl = this.configurationService.get('ROOT_URL'); |
|||
|
|||
return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { |
|||
const params = { |
|||
currentDate, |
|||
languageCode, |
|||
rootUrl |
|||
}; |
|||
|
|||
return [ |
|||
this.createRouteSitemapUrl(params), |
|||
...this.createSitemapUrls(params, publicRoutes) |
|||
]; |
|||
}).join('\n'); |
|||
} |
|||
|
|||
private createRouteSitemapUrl({ |
|||
currentDate, |
|||
languageCode, |
|||
rootUrl, |
|||
route |
|||
}: { |
|||
currentDate: string; |
|||
languageCode: string; |
|||
rootUrl: string; |
|||
route?: PublicRoute; |
|||
}): string { |
|||
const segments = |
|||
route?.routerLink.map((link) => { |
|||
const match = link.match( |
|||
SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX |
|||
); |
|||
|
|||
const segment = match |
|||
? (this.i18nService.getTranslation({ |
|||
languageCode, |
|||
id: match.groups.id |
|||
}) ?? match.groups.message) |
|||
: link; |
|||
|
|||
return segment.replace(/^\/+|\/+$/, ''); |
|||
}) ?? []; |
|||
|
|||
const location = [rootUrl, languageCode, ...segments].join('/'); |
|||
|
|||
return [ |
|||
' <url>', |
|||
` <loc>${location}</loc>`, |
|||
` <lastmod>${currentDate}T00:00:00+00:00</lastmod>`, |
|||
' </url>' |
|||
].join('\n'); |
|||
} |
|||
|
|||
private createSitemapUrls( |
|||
params: { currentDate: string; languageCode: string; rootUrl: string }, |
|||
routes: Record<string, PublicRoute> |
|||
): string[] { |
|||
return Object.values(routes).flatMap((route) => { |
|||
if (route.excludeFromSitemap) { |
|||
return []; |
|||
} |
|||
|
|||
const urls = [this.createRouteSitemapUrl({ ...params, route })]; |
|||
|
|||
if (route.subRoutes) { |
|||
urls.push(...this.createSitemapUrls(params, route.subRoutes)); |
|||
} |
|||
|
|||
return urls; |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,113 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; |
|||
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { 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 { Tag } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
@Controller('tags') |
|||
export class TagsController { |
|||
public constructor( |
|||
@Inject(REQUEST) private readonly request: RequestWithUser, |
|||
private readonly tagService: TagService |
|||
) {} |
|||
|
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt')) |
|||
public async createTag(@Body() data: CreateTagDto): Promise<Tag> { |
|||
const canCreateOwnTag = hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.createOwnTag |
|||
); |
|||
|
|||
const canCreateTag = hasPermission( |
|||
this.request.user.permissions, |
|||
permissions.createTag |
|||
); |
|||
|
|||
if (!canCreateOwnTag && !canCreateTag) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
if (canCreateOwnTag && !canCreateTag) { |
|||
if (data.userId !== this.request.user.id) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
} |
|||
|
|||
return this.tagService.createTag(data); |
|||
} |
|||
|
|||
@Delete(':id') |
|||
@HasPermission(permissions.deleteTag) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteTag(@Param('id') id: string) { |
|||
const originalTag = await this.tagService.getTag({ |
|||
id |
|||
}); |
|||
|
|||
if (!originalTag) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.tagService.deleteTag({ id }); |
|||
} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.readTags) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getTags() { |
|||
return this.tagService.getTagsWithActivityCount(); |
|||
} |
|||
|
|||
@HasPermission(permissions.updateTag) |
|||
@Put(':id') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { |
|||
const originalTag = await this.tagService.getTag({ |
|||
id |
|||
}); |
|||
|
|||
if (!originalTag) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.tagService.updateTag({ |
|||
data: { |
|||
...data |
|||
}, |
|||
where: { |
|||
id |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { TagsController } from './tags.controller'; |
|||
|
|||
@Module({ |
|||
controllers: [TagsController], |
|||
imports: [PrismaModule, TagModule] |
|||
}) |
|||
export class TagsModule {} |
|||
@ -0,0 +1,100 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
|||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; |
|||
import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos'; |
|||
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
Headers, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { WatchlistService } from './watchlist.service'; |
|||
|
|||
@Controller('watchlist') |
|||
export class WatchlistController { |
|||
public constructor( |
|||
private readonly impersonationService: ImpersonationService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser, |
|||
private readonly watchlistService: WatchlistService |
|||
) {} |
|||
|
|||
@Post() |
|||
@HasPermission(permissions.createWatchlistItem) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) { |
|||
return this.watchlistService.createWatchlistItem({ |
|||
dataSource: data.dataSource, |
|||
symbol: data.symbol, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Delete(':dataSource/:symbol') |
|||
@HasPermission(permissions.deleteWatchlistItem) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async deleteWatchlistItem( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
) { |
|||
const watchlistItems = await this.watchlistService.getWatchlistItems( |
|||
this.request.user.id |
|||
); |
|||
|
|||
const watchlistItem = watchlistItems.find((item) => { |
|||
return item.dataSource === dataSource && item.symbol === symbol; |
|||
}); |
|||
|
|||
if (!watchlistItem) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return this.watchlistService.deleteWatchlistItem({ |
|||
dataSource, |
|||
symbol, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.readWatchlist) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getWatchlistItems( |
|||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string |
|||
): Promise<WatchlistResponse> { |
|||
const impersonationUserId = |
|||
await this.impersonationService.validateImpersonationId(impersonationId); |
|||
|
|||
const watchlist = await this.watchlistService.getWatchlistItems( |
|||
impersonationUserId || this.request.user.id |
|||
); |
|||
|
|||
return { |
|||
watchlist |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
|||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; |
|||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { WatchlistController } from './watchlist.controller'; |
|||
import { WatchlistService } from './watchlist.service'; |
|||
|
|||
@Module({ |
|||
controllers: [WatchlistController], |
|||
imports: [ |
|||
BenchmarkModule, |
|||
DataGatheringModule, |
|||
DataProviderModule, |
|||
ImpersonationModule, |
|||
MarketDataModule, |
|||
PrismaModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule |
|||
], |
|||
providers: [WatchlistService] |
|||
}) |
|||
export class WatchlistModule {} |
|||
@ -0,0 +1,155 @@ |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { BadRequestException, Injectable } from '@nestjs/common'; |
|||
import { DataSource, Prisma } from '@prisma/client'; |
|||
|
|||
@Injectable() |
|||
export class WatchlistService { |
|||
public constructor( |
|||
private readonly benchmarkService: BenchmarkService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly marketDataService: MarketDataService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
public async createWatchlistItem({ |
|||
dataSource, |
|||
symbol, |
|||
userId |
|||
}: { |
|||
dataSource: DataSource; |
|||
symbol: string; |
|||
userId: string; |
|||
}): Promise<void> { |
|||
const symbolProfile = await this.prismaService.symbolProfile.findUnique({ |
|||
where: { |
|||
dataSource_symbol: { dataSource, symbol } |
|||
} |
|||
}); |
|||
|
|||
if (!symbolProfile) { |
|||
const assetProfiles = await this.dataProviderService.getAssetProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfiles[symbol]?.currency) { |
|||
throw new BadRequestException( |
|||
`Asset profile not found for ${symbol} (${dataSource})` |
|||
); |
|||
} |
|||
|
|||
await this.symbolProfileService.add( |
|||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput |
|||
); |
|||
} |
|||
|
|||
await this.dataGatheringService.gatherSymbol({ |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
|
|||
await this.prismaService.user.update({ |
|||
data: { |
|||
watchlist: { |
|||
connect: { |
|||
dataSource_symbol: { dataSource, symbol } |
|||
} |
|||
} |
|||
}, |
|||
where: { id: userId } |
|||
}); |
|||
} |
|||
|
|||
public async deleteWatchlistItem({ |
|||
dataSource, |
|||
symbol, |
|||
userId |
|||
}: { |
|||
dataSource: DataSource; |
|||
symbol: string; |
|||
userId: string; |
|||
}) { |
|||
await this.prismaService.user.update({ |
|||
data: { |
|||
watchlist: { |
|||
disconnect: { |
|||
dataSource_symbol: { dataSource, symbol } |
|||
} |
|||
} |
|||
}, |
|||
where: { id: userId } |
|||
}); |
|||
} |
|||
|
|||
public async getWatchlistItems( |
|||
userId: string |
|||
): Promise<WatchlistResponse['watchlist']> { |
|||
const user = await this.prismaService.user.findUnique({ |
|||
select: { |
|||
watchlist: { |
|||
select: { dataSource: true, symbol: true } |
|||
} |
|||
}, |
|||
where: { id: userId } |
|||
}); |
|||
|
|||
const [assetProfiles, quotes] = await Promise.all([ |
|||
this.symbolProfileService.getSymbolProfiles(user.watchlist), |
|||
this.dataProviderService.getQuotes({ |
|||
items: user.watchlist.map(({ dataSource, symbol }) => { |
|||
return { dataSource, symbol }; |
|||
}) |
|||
}) |
|||
]); |
|||
|
|||
const watchlist = await Promise.all( |
|||
user.watchlist.map(async ({ dataSource, symbol }) => { |
|||
const assetProfile = assetProfiles.find((profile) => { |
|||
return profile.dataSource === dataSource && profile.symbol === symbol; |
|||
}); |
|||
|
|||
const [allTimeHigh, trends] = await Promise.all([ |
|||
this.marketDataService.getMax({ |
|||
dataSource, |
|||
symbol |
|||
}), |
|||
this.benchmarkService.getBenchmarkTrends({ dataSource, symbol }) |
|||
]); |
|||
|
|||
const performancePercent = |
|||
this.benchmarkService.calculateChangeInPercentage( |
|||
allTimeHigh?.marketPrice, |
|||
quotes[symbol]?.marketPrice |
|||
); |
|||
|
|||
return { |
|||
dataSource, |
|||
symbol, |
|||
marketCondition: |
|||
this.benchmarkService.getMarketCondition(performancePercent), |
|||
name: assetProfile?.name, |
|||
performances: { |
|||
allTimeHigh: { |
|||
performancePercent, |
|||
date: allTimeHigh?.date |
|||
} |
|||
}, |
|||
trend50d: trends.trend50d, |
|||
trend200d: trends.trend200d |
|||
}; |
|||
}) |
|||
); |
|||
|
|||
return watchlist.sort((a, b) => { |
|||
return a.name.localeCompare(b.name); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { DataProviderHistoricalResponse } from '@ghostfolio/common/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'), HasPermissionGuard) |
|||
public async getExchangeRate( |
|||
@Param('dateString') dateString: string, |
|||
@Param('symbol') symbol: string |
|||
): Promise<DataProviderHistoricalResponse> { |
|||
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,14 @@ |
|||
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,27 @@ |
|||
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 |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { ExportResponse } from '@ghostfolio/common/interfaces'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Controller, |
|||
Get, |
|||
Inject, |
|||
Query, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
|
|||
import { ExportService } from './export.service'; |
|||
|
|||
@Controller('export') |
|||
export class ExportController { |
|||
public constructor( |
|||
private readonly apiService: ApiService, |
|||
private readonly exportService: ExportService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async export( |
|||
@Query('accounts') filterByAccounts?: string, |
|||
@Query('activityIds') filterByActivityIds?: string, |
|||
@Query('assetClasses') filterByAssetClasses?: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('symbol') filterBySymbol?: string, |
|||
@Query('tags') filterByTags?: string |
|||
): Promise<ExportResponse> { |
|||
const activityIds = filterByActivityIds?.split(',') ?? []; |
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAccounts, |
|||
filterByAssetClasses, |
|||
filterByDataSource, |
|||
filterBySymbol, |
|||
filterByTags |
|||
}); |
|||
|
|||
return this.exportService.export({ |
|||
activityIds, |
|||
filters, |
|||
userId: this.request.user.id, |
|||
userSettings: this.request.user.settings.settings |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; |
|||
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { ExportController } from './export.controller'; |
|||
import { ExportService } from './export.service'; |
|||
|
|||
@Module({ |
|||
controllers: [ExportController], |
|||
imports: [ |
|||
AccountModule, |
|||
ApiModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
TagModule, |
|||
TransformDataSourceInRequestModule |
|||
], |
|||
providers: [ExportService] |
|||
}) |
|||
export class ExportModule {} |
|||
@ -0,0 +1,258 @@ |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
|||
import { environment } from '@ghostfolio/api/environments/environment'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; |
|||
import { |
|||
ExportResponse, |
|||
Filter, |
|||
UserSettings |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { Platform, Prisma } from '@prisma/client'; |
|||
import { groupBy, uniqBy } from 'lodash'; |
|||
|
|||
@Injectable() |
|||
export class ExportService { |
|||
public constructor( |
|||
private readonly accountService: AccountService, |
|||
private readonly marketDataService: MarketDataService, |
|||
private readonly orderService: OrderService, |
|||
private readonly tagService: TagService |
|||
) {} |
|||
|
|||
public async export({ |
|||
activityIds, |
|||
filters, |
|||
userId, |
|||
userSettings |
|||
}: { |
|||
activityIds?: string[]; |
|||
filters?: Filter[]; |
|||
userId: string; |
|||
userSettings: UserSettings; |
|||
}): Promise<ExportResponse> { |
|||
const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => { |
|||
return type; |
|||
}); |
|||
const platformsMap: { [platformId: string]: Platform } = {}; |
|||
|
|||
let { activities } = await this.orderService.getOrders({ |
|||
filters, |
|||
userId, |
|||
includeDrafts: true, |
|||
sortColumn: 'date', |
|||
sortDirection: 'asc', |
|||
userCurrency: userSettings?.baseCurrency, |
|||
withExcludedAccountsAndActivities: true |
|||
}); |
|||
|
|||
if (activityIds?.length > 0) { |
|||
activities = activities.filter(({ id }) => { |
|||
return activityIds.includes(id); |
|||
}); |
|||
} |
|||
|
|||
const where: Prisma.AccountWhereInput = { userId }; |
|||
|
|||
if (filtersByAccount?.length > 0) { |
|||
where.id = { |
|||
in: filtersByAccount.map(({ id }) => { |
|||
return id; |
|||
}) |
|||
}; |
|||
} |
|||
|
|||
const accounts = ( |
|||
await this.accountService.accounts({ |
|||
where, |
|||
include: { |
|||
balances: true, |
|||
platform: true |
|||
}, |
|||
orderBy: { |
|||
name: 'asc' |
|||
} |
|||
}) |
|||
) |
|||
.filter(({ id }) => { |
|||
return activityIds?.length > 0 |
|||
? activities.some(({ accountId }) => { |
|||
return accountId === id; |
|||
}) |
|||
: true; |
|||
}) |
|||
.map( |
|||
({ |
|||
balance, |
|||
balances, |
|||
comment, |
|||
currency, |
|||
id, |
|||
isExcluded, |
|||
name, |
|||
platform, |
|||
platformId |
|||
}) => { |
|||
if (platformId) { |
|||
platformsMap[platformId] = platform; |
|||
} |
|||
|
|||
return { |
|||
balance, |
|||
balances: balances.map(({ date, value }) => { |
|||
return { date: date.toISOString(), value }; |
|||
}), |
|||
comment, |
|||
currency, |
|||
id, |
|||
isExcluded, |
|||
name, |
|||
platformId |
|||
}; |
|||
} |
|||
); |
|||
|
|||
const customAssetProfiles = uniqBy( |
|||
activities |
|||
.map(({ SymbolProfile }) => { |
|||
return SymbolProfile; |
|||
}) |
|||
.filter(({ userId: assetProfileUserId }) => { |
|||
return assetProfileUserId === userId; |
|||
}), |
|||
({ id }) => { |
|||
return id; |
|||
} |
|||
); |
|||
|
|||
const marketDataByAssetProfile = Object.fromEntries( |
|||
await Promise.all( |
|||
customAssetProfiles.map(async ({ dataSource, id, symbol }) => { |
|||
const marketData = ( |
|||
await this.marketDataService.marketDataItems({ |
|||
where: { dataSource, symbol } |
|||
}) |
|||
).map(({ date, marketPrice }) => ({ |
|||
date: date.toISOString(), |
|||
marketPrice |
|||
})); |
|||
|
|||
return [id, marketData] as const; |
|||
}) |
|||
) |
|||
); |
|||
|
|||
const tags = (await this.tagService.getTagsForUser(userId)) |
|||
.filter(({ id, isUsed }) => { |
|||
return ( |
|||
isUsed && |
|||
activities.some((activity) => { |
|||
return activity.tags.some(({ id: tagId }) => { |
|||
return tagId === id; |
|||
}); |
|||
}) |
|||
); |
|||
}) |
|||
.map(({ id, name }) => { |
|||
return { |
|||
id, |
|||
name |
|||
}; |
|||
}); |
|||
|
|||
return { |
|||
meta: { date: new Date().toISOString(), version: environment.version }, |
|||
accounts, |
|||
assetProfiles: customAssetProfiles.map( |
|||
({ |
|||
assetClass, |
|||
assetSubClass, |
|||
comment, |
|||
countries, |
|||
currency, |
|||
cusip, |
|||
dataSource, |
|||
figi, |
|||
figiComposite, |
|||
figiShareClass, |
|||
holdings, |
|||
id, |
|||
isActive, |
|||
isin, |
|||
name, |
|||
scraperConfiguration, |
|||
sectors, |
|||
symbol, |
|||
symbolMapping, |
|||
url |
|||
}) => { |
|||
return { |
|||
assetClass, |
|||
assetSubClass, |
|||
comment, |
|||
countries: countries as unknown as Prisma.JsonArray, |
|||
currency, |
|||
cusip, |
|||
dataSource, |
|||
figi, |
|||
figiComposite, |
|||
figiShareClass, |
|||
holdings: holdings as unknown as Prisma.JsonArray, |
|||
isActive, |
|||
isin, |
|||
marketData: marketDataByAssetProfile[id], |
|||
name, |
|||
scraperConfiguration: |
|||
scraperConfiguration as unknown as Prisma.JsonArray, |
|||
sectors: sectors as unknown as Prisma.JsonArray, |
|||
symbol, |
|||
symbolMapping, |
|||
url |
|||
}; |
|||
} |
|||
), |
|||
platforms: Object.values(platformsMap), |
|||
tags, |
|||
activities: activities.map( |
|||
({ |
|||
accountId, |
|||
comment, |
|||
currency, |
|||
date, |
|||
fee, |
|||
id, |
|||
quantity, |
|||
SymbolProfile, |
|||
tags: currentTags, |
|||
type, |
|||
unitPrice |
|||
}) => { |
|||
return { |
|||
accountId, |
|||
comment, |
|||
fee, |
|||
id, |
|||
quantity, |
|||
type, |
|||
unitPrice, |
|||
currency: currency ?? SymbolProfile.currency, |
|||
dataSource: SymbolProfile.dataSource, |
|||
date: date.toISOString(), |
|||
symbol: SymbolProfile.symbol, |
|||
tags: currentTags.map(({ id: tagId }) => { |
|||
return tagId; |
|||
}) |
|||
}; |
|||
} |
|||
), |
|||
user: { |
|||
settings: { |
|||
currency: userSettings?.baseCurrency, |
|||
performanceCalculationType: userSettings?.performanceCalculationType |
|||
} |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { |
|||
DataEnhancerHealthResponse, |
|||
DataProviderHealthResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
HttpStatus, |
|||
Param, |
|||
Res, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { Response } from 'express'; |
|||
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(@Res() response: Response) { |
|||
const databaseServiceHealthy = await this.healthService.isDatabaseHealthy(); |
|||
const redisCacheServiceHealthy = |
|||
await this.healthService.isRedisCacheHealthy(); |
|||
|
|||
if (databaseServiceHealthy && redisCacheServiceHealthy) { |
|||
return response |
|||
.status(HttpStatus.OK) |
|||
.json({ status: getReasonPhrase(StatusCodes.OK) }); |
|||
} else { |
|||
return response |
|||
.status(HttpStatus.SERVICE_UNAVAILABLE) |
|||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); |
|||
} |
|||
} |
|||
|
|||
@Get('data-enhancer/:name') |
|||
public async getHealthOfDataEnhancer( |
|||
@Param('name') name: string, |
|||
@Res() response: Response |
|||
): Promise<Response<DataEnhancerHealthResponse>> { |
|||
const hasResponse = |
|||
await this.healthService.hasResponseFromDataEnhancer(name); |
|||
|
|||
if (hasResponse) { |
|||
return response.status(HttpStatus.OK).json({ |
|||
status: getReasonPhrase(StatusCodes.OK) |
|||
}); |
|||
} else { |
|||
return response |
|||
.status(HttpStatus.SERVICE_UNAVAILABLE) |
|||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); |
|||
} |
|||
} |
|||
|
|||
@Get('data-provider/:dataSource') |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async getHealthOfDataProvider( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Res() response: Response |
|||
): Promise<Response<DataProviderHealthResponse>> { |
|||
if (!DataSource[dataSource]) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const hasResponse = |
|||
await this.healthService.hasResponseFromDataProvider(dataSource); |
|||
|
|||
if (hasResponse) { |
|||
return response |
|||
.status(HttpStatus.OK) |
|||
.json({ status: getReasonPhrase(StatusCodes.OK) }); |
|||
} else { |
|||
return response |
|||
.status(HttpStatus.SERVICE_UNAVAILABLE) |
|||
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,23 @@ |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.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 { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { HealthController } from './health.controller'; |
|||
import { HealthService } from './health.service'; |
|||
|
|||
@Module({ |
|||
controllers: [HealthController], |
|||
imports: [ |
|||
DataEnhancerModule, |
|||
DataProviderModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
TransformDataSourceInRequestModule |
|||
], |
|||
providers: [HealthService] |
|||
}) |
|||
export class HealthModule {} |
|||
@ -0,0 +1,46 @@ |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
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 { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
|
|||
@Injectable() |
|||
export class HealthService { |
|||
public constructor( |
|||
private readonly dataEnhancerService: DataEnhancerService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly propertyService: PropertyService, |
|||
private readonly redisCacheService: RedisCacheService |
|||
) {} |
|||
|
|||
public async hasResponseFromDataEnhancer(aName: string) { |
|||
return this.dataEnhancerService.enhance(aName); |
|||
} |
|||
|
|||
public async hasResponseFromDataProvider(aDataSource: DataSource) { |
|||
return this.dataProviderService.checkQuote(aDataSource); |
|||
} |
|||
|
|||
public async isDatabaseHealthy() { |
|||
try { |
|||
await this.propertyService.getByKey(PROPERTY_CURRENCIES); |
|||
|
|||
return true; |
|||
} catch { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
public async isRedisCacheHealthy() { |
|||
try { |
|||
const isHealthy = await this.redisCacheService.isHealthy(); |
|||
|
|||
return isHealthy; |
|||
} catch { |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
import { |
|||
CreateAccountWithBalancesDto, |
|||
CreateAssetProfileWithMarketDataDto, |
|||
CreateOrderDto, |
|||
CreateTagDto |
|||
} from '@ghostfolio/common/dtos'; |
|||
|
|||
import { Type } from 'class-transformer'; |
|||
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; |
|||
|
|||
export class ImportDataDto { |
|||
@IsArray() |
|||
@IsOptional() |
|||
@Type(() => CreateAccountWithBalancesDto) |
|||
@ValidateNested({ each: true }) |
|||
accounts?: CreateAccountWithBalancesDto[]; |
|||
|
|||
@IsArray() |
|||
@Type(() => CreateOrderDto) |
|||
@ValidateNested({ each: true }) |
|||
activities: CreateOrderDto[]; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
@Type(() => CreateAssetProfileWithMarketDataDto) |
|||
@ValidateNested({ each: true }) |
|||
assetProfiles?: CreateAssetProfileWithMarketDataDto[]; |
|||
|
|||
@IsArray() |
|||
@IsOptional() |
|||
@Type(() => CreateTagDto) |
|||
@ValidateNested({ each: true }) |
|||
tags?: CreateTagDto[]; |
|||
} |
|||
@ -0,0 +1,112 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ImportResponse } from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import type { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Logger, |
|||
Param, |
|||
Post, |
|||
Query, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { ImportDataDto } from './import-data.dto'; |
|||
import { ImportService } from './import.service'; |
|||
|
|||
@Controller('import') |
|||
export class ImportController { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly importService: ImportService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@HasPermission(permissions.createOrder) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async import( |
|||
@Body() importData: ImportDataDto, |
|||
@Query('dryRun') isDryRunParam = 'false' |
|||
): Promise<ImportResponse> { |
|||
const isDryRun = isDryRunParam === 'true'; |
|||
|
|||
if ( |
|||
!hasPermission(this.request.user.permissions, permissions.createAccount) |
|||
) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
let maxActivitiesToImport = this.configurationService.get( |
|||
'MAX_ACTIVITIES_TO_IMPORT' |
|||
); |
|||
|
|||
if ( |
|||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && |
|||
this.request.user.subscription.type === 'Premium' |
|||
) { |
|||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER; |
|||
} |
|||
|
|||
try { |
|||
const activities = await this.importService.import({ |
|||
isDryRun, |
|||
maxActivitiesToImport, |
|||
accountsWithBalancesDto: importData.accounts ?? [], |
|||
activitiesDto: importData.activities, |
|||
assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], |
|||
tagsDto: importData.tags ?? [], |
|||
user: this.request.user |
|||
}); |
|||
|
|||
return { activities }; |
|||
} catch (error) { |
|||
Logger.error(error, ImportController); |
|||
|
|||
throw new HttpException( |
|||
{ |
|||
error: getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
message: [error.message] |
|||
}, |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
} |
|||
|
|||
@Get('dividends/:dataSource/:symbol') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async gatherDividends( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
): Promise<ImportResponse> { |
|||
const activities = await this.importService.getDividends({ |
|||
dataSource, |
|||
symbol, |
|||
userCurrency: this.request.user.settings.settings.baseCurrency, |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
return { activities }; |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; |
|||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; |
|||
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; |
|||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; |
|||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { ImportController } from './import.controller'; |
|||
import { ImportService } from './import.service'; |
|||
|
|||
@Module({ |
|||
controllers: [ImportController], |
|||
imports: [ |
|||
AccountModule, |
|||
ApiModule, |
|||
CacheModule, |
|||
ConfigurationModule, |
|||
DataGatheringModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
MarketDataModule, |
|||
OrderModule, |
|||
PlatformModule, |
|||
PortfolioModule, |
|||
PrismaModule, |
|||
RedisCacheModule, |
|||
SymbolProfileModule, |
|||
TagModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule |
|||
], |
|||
providers: [ImportService] |
|||
}) |
|||
export class ImportModule {} |
|||
@ -0,0 +1,730 @@ |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
|||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; |
|||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; |
|||
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; |
|||
import { |
|||
CreateAssetProfileDto, |
|||
CreateAccountDto, |
|||
CreateOrderDto |
|||
} from '@ghostfolio/common/dtos'; |
|||
import { |
|||
getAssetProfileIdentifier, |
|||
parseDate |
|||
} from '@ghostfolio/common/helper'; |
|||
import { |
|||
Activity, |
|||
ActivityError, |
|||
AssetProfileIdentifier |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
|||
import { |
|||
AccountWithValue, |
|||
OrderWithAccount, |
|||
UserWithSettings |
|||
} from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { DataSource, Prisma } from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; |
|||
import { omit, uniqBy } from 'lodash'; |
|||
import { randomUUID } from 'node:crypto'; |
|||
|
|||
import { ImportDataDto } from './import-data.dto'; |
|||
|
|||
@Injectable() |
|||
export class ImportService { |
|||
public constructor( |
|||
private readonly accountService: AccountService, |
|||
private readonly apiService: ApiService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly marketDataService: MarketDataService, |
|||
private readonly orderService: OrderService, |
|||
private readonly platformService: PlatformService, |
|||
private readonly portfolioService: PortfolioService, |
|||
private readonly symbolProfileService: SymbolProfileService, |
|||
private readonly tagService: TagService |
|||
) {} |
|||
|
|||
public async getDividends({ |
|||
dataSource, |
|||
symbol, |
|||
userCurrency, |
|||
userId |
|||
}: AssetProfileIdentifier & { |
|||
userCurrency: string; |
|||
userId: string; |
|||
}): Promise<Activity[]> { |
|||
try { |
|||
const holding = await this.portfolioService.getHolding({ |
|||
dataSource, |
|||
symbol, |
|||
userId, |
|||
impersonationId: undefined |
|||
}); |
|||
|
|||
if (!holding) { |
|||
return []; |
|||
} |
|||
|
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByDataSource: dataSource, |
|||
filterBySymbol: symbol |
|||
}); |
|||
|
|||
const { dateOfFirstActivity, historicalData } = holding; |
|||
|
|||
const [{ accounts }, { activities }, [assetProfile], dividends] = |
|||
await Promise.all([ |
|||
this.portfolioService.getAccountsWithAggregations({ |
|||
filters, |
|||
userId, |
|||
withExcludedAccounts: true |
|||
}), |
|||
this.orderService.getOrders({ |
|||
filters, |
|||
userCurrency, |
|||
userId, |
|||
startDate: parseDate(dateOfFirstActivity) |
|||
}), |
|||
this.symbolProfileService.getSymbolProfiles([ |
|||
{ |
|||
dataSource, |
|||
symbol |
|||
} |
|||
]), |
|||
await this.dataProviderService.getDividends({ |
|||
dataSource, |
|||
symbol, |
|||
from: parseDate(dateOfFirstActivity), |
|||
granularity: 'day', |
|||
to: new Date() |
|||
}) |
|||
]); |
|||
|
|||
const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; |
|||
|
|||
return await Promise.all( |
|||
Object.entries(dividends).map(([dateString, { marketPrice }]) => { |
|||
const quantity = |
|||
historicalData.find((historicalDataItem) => { |
|||
return historicalDataItem.date === dateString; |
|||
})?.quantity ?? 0; |
|||
|
|||
const value = new Big(quantity).mul(marketPrice).toNumber(); |
|||
|
|||
const date = parseDate(dateString); |
|||
const isDuplicate = activities.some((activity) => { |
|||
return ( |
|||
activity.accountId === account?.id && |
|||
activity.SymbolProfile.currency === assetProfile.currency && |
|||
activity.SymbolProfile.dataSource === assetProfile.dataSource && |
|||
isSameSecond(activity.date, date) && |
|||
activity.quantity === quantity && |
|||
activity.SymbolProfile.symbol === assetProfile.symbol && |
|||
activity.type === 'DIVIDEND' && |
|||
activity.unitPrice === marketPrice |
|||
); |
|||
}); |
|||
|
|||
const error: ActivityError = isDuplicate |
|||
? { code: 'IS_DUPLICATE' } |
|||
: undefined; |
|||
|
|||
return { |
|||
account, |
|||
date, |
|||
error, |
|||
quantity, |
|||
value, |
|||
accountId: account?.id, |
|||
accountUserId: undefined, |
|||
comment: undefined, |
|||
currency: undefined, |
|||
createdAt: undefined, |
|||
fee: 0, |
|||
feeInAssetProfileCurrency: 0, |
|||
feeInBaseCurrency: 0, |
|||
id: assetProfile.id, |
|||
isDraft: false, |
|||
SymbolProfile: assetProfile, |
|||
symbolProfileId: assetProfile.id, |
|||
type: 'DIVIDEND', |
|||
unitPrice: marketPrice, |
|||
unitPriceInAssetProfileCurrency: marketPrice, |
|||
updatedAt: undefined, |
|||
userId: account?.userId, |
|||
valueInBaseCurrency: value |
|||
}; |
|||
}) |
|||
); |
|||
} catch { |
|||
return []; |
|||
} |
|||
} |
|||
|
|||
public async import({ |
|||
accountsWithBalancesDto, |
|||
activitiesDto, |
|||
assetProfilesWithMarketDataDto, |
|||
isDryRun = false, |
|||
maxActivitiesToImport, |
|||
tagsDto, |
|||
user |
|||
}: { |
|||
accountsWithBalancesDto: ImportDataDto['accounts']; |
|||
activitiesDto: ImportDataDto['activities']; |
|||
assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; |
|||
isDryRun?: boolean; |
|||
maxActivitiesToImport: number; |
|||
tagsDto: ImportDataDto['tags']; |
|||
user: UserWithSettings; |
|||
}): Promise<Activity[]> { |
|||
const accountIdMapping: { [oldAccountId: string]: string } = {}; |
|||
const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; |
|||
const tagIdMapping: { [oldTagId: string]: string } = {}; |
|||
const userCurrency = user.settings.settings.baseCurrency; |
|||
|
|||
if (!isDryRun && accountsWithBalancesDto?.length) { |
|||
const [existingAccounts, existingPlatforms] = await Promise.all([ |
|||
this.accountService.accounts({ |
|||
where: { |
|||
id: { |
|||
in: accountsWithBalancesDto.map(({ id }) => { |
|||
return id; |
|||
}) |
|||
} |
|||
} |
|||
}), |
|||
this.platformService.getPlatforms() |
|||
]); |
|||
|
|||
for (const accountWithBalances of accountsWithBalancesDto) { |
|||
// Check if there is any existing account with the same ID
|
|||
const accountWithSameId = existingAccounts.find((existingAccount) => { |
|||
return existingAccount.id === accountWithBalances.id; |
|||
}); |
|||
|
|||
// If there is no account or if the account belongs to a different user then create a new account
|
|||
if (!accountWithSameId || accountWithSameId.userId !== user.id) { |
|||
const account: CreateAccountDto = omit( |
|||
accountWithBalances, |
|||
'balances' |
|||
); |
|||
|
|||
let oldAccountId: string; |
|||
const platformId = account.platformId; |
|||
|
|||
delete account.platformId; |
|||
|
|||
if (accountWithSameId) { |
|||
oldAccountId = account.id; |
|||
delete account.id; |
|||
} |
|||
|
|||
let accountObject: Prisma.AccountCreateInput = { |
|||
...account, |
|||
balances: { |
|||
create: accountWithBalances.balances ?? [] |
|||
}, |
|||
user: { connect: { id: user.id } } |
|||
}; |
|||
|
|||
if ( |
|||
existingPlatforms.some(({ id }) => { |
|||
return id === platformId; |
|||
}) |
|||
) { |
|||
accountObject = { |
|||
...accountObject, |
|||
platform: { connect: { id: platformId } } |
|||
}; |
|||
} |
|||
|
|||
const newAccount = await this.accountService.createAccount( |
|||
accountObject, |
|||
user.id |
|||
); |
|||
|
|||
// Store the new to old account ID mappings for updating activities
|
|||
if (accountWithSameId && oldAccountId) { |
|||
accountIdMapping[oldAccountId] = newAccount.id; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (!isDryRun && assetProfilesWithMarketDataDto?.length) { |
|||
const existingAssetProfiles = |
|||
await this.symbolProfileService.getSymbolProfiles( |
|||
assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { |
|||
return { dataSource, symbol }; |
|||
}) |
|||
); |
|||
|
|||
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { |
|||
// Check if there is any existing asset profile
|
|||
const existingAssetProfile = existingAssetProfiles.find( |
|||
({ dataSource, symbol }) => { |
|||
return ( |
|||
dataSource === assetProfileWithMarketData.dataSource && |
|||
symbol === assetProfileWithMarketData.symbol |
|||
); |
|||
} |
|||
); |
|||
|
|||
// If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile
|
|||
if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { |
|||
const assetProfile: CreateAssetProfileDto = omit( |
|||
assetProfileWithMarketData, |
|||
'marketData' |
|||
); |
|||
|
|||
// Asset profile belongs to a different user
|
|||
if (existingAssetProfile) { |
|||
const symbol = randomUUID(); |
|||
assetProfileSymbolMapping[assetProfile.symbol] = symbol; |
|||
assetProfile.symbol = symbol; |
|||
} |
|||
|
|||
// Create a new asset profile
|
|||
const assetProfileObject: Prisma.SymbolProfileCreateInput = { |
|||
...assetProfile, |
|||
user: { connect: { id: user.id } } |
|||
}; |
|||
|
|||
await this.symbolProfileService.add(assetProfileObject); |
|||
} |
|||
|
|||
// Insert or update market data
|
|||
const marketDataObjects = assetProfileWithMarketData.marketData.map( |
|||
(marketData) => { |
|||
return { |
|||
...marketData, |
|||
dataSource: assetProfileWithMarketData.dataSource, |
|||
symbol: assetProfileWithMarketData.symbol |
|||
} as Prisma.MarketDataUpdateInput; |
|||
} |
|||
); |
|||
|
|||
await this.marketDataService.updateMany({ data: marketDataObjects }); |
|||
} |
|||
} |
|||
|
|||
if (tagsDto?.length) { |
|||
const existingTagsOfUser = await this.tagService.getTagsForUser(user.id); |
|||
|
|||
const canCreateOwnTag = hasPermission( |
|||
user.permissions, |
|||
permissions.createOwnTag |
|||
); |
|||
|
|||
for (const tag of tagsDto) { |
|||
const existingTagOfUser = existingTagsOfUser.find(({ id }) => { |
|||
return id === tag.id; |
|||
}); |
|||
|
|||
if (!existingTagOfUser || existingTagOfUser.userId !== null) { |
|||
if (!canCreateOwnTag) { |
|||
throw new Error( |
|||
`Insufficient permissions to create custom tag ("${tag.name}")` |
|||
); |
|||
} |
|||
|
|||
if (!isDryRun) { |
|||
const existingTag = await this.tagService.getTag({ id: tag.id }); |
|||
let oldTagId: string; |
|||
|
|||
if (existingTag) { |
|||
oldTagId = tag.id; |
|||
delete tag.id; |
|||
} |
|||
|
|||
const tagObject: Prisma.TagCreateInput = { |
|||
...tag, |
|||
user: { connect: { id: user.id } } |
|||
}; |
|||
|
|||
const newTag = await this.tagService.createTag(tagObject); |
|||
|
|||
if (existingTag && oldTagId) { |
|||
tagIdMapping[oldTagId] = newTag.id; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
for (const activity of activitiesDto) { |
|||
if (!activity.dataSource) { |
|||
if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { |
|||
activity.dataSource = DataSource.MANUAL; |
|||
} else { |
|||
activity.dataSource = |
|||
this.dataProviderService.getDataSourceForImport(); |
|||
} |
|||
} |
|||
|
|||
if (!isDryRun) { |
|||
// If a new account is created, then update the accountId in all activities
|
|||
if (accountIdMapping[activity.accountId]) { |
|||
activity.accountId = accountIdMapping[activity.accountId]; |
|||
} |
|||
|
|||
// If a new asset profile is created, then update the symbol in all activities
|
|||
if (assetProfileSymbolMapping[activity.symbol]) { |
|||
activity.symbol = assetProfileSymbolMapping[activity.symbol]; |
|||
} |
|||
|
|||
// If a new tag is created, then update the tag ID in all activities
|
|||
activity.tags = (activity.tags ?? []).map((tagId) => { |
|||
return tagIdMapping[tagId] ?? tagId; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
const assetProfiles = await this.dataProviderService.validateActivities({ |
|||
activitiesDto, |
|||
assetProfilesWithMarketDataDto, |
|||
maxActivitiesToImport, |
|||
user |
|||
}); |
|||
|
|||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ |
|||
activitiesDto, |
|||
userCurrency, |
|||
userId: user.id |
|||
}); |
|||
|
|||
const accounts = (await this.accountService.getAccounts(user.id)).map( |
|||
({ id, name }) => { |
|||
return { id, name }; |
|||
} |
|||
); |
|||
|
|||
if (isDryRun) { |
|||
accountsWithBalancesDto.forEach(({ id, name }) => { |
|||
accounts.push({ id, name }); |
|||
}); |
|||
} |
|||
|
|||
const tags = (await this.tagService.getTagsForUser(user.id)).map( |
|||
({ id, name }) => { |
|||
return { id, name }; |
|||
} |
|||
); |
|||
|
|||
if (isDryRun) { |
|||
tagsDto |
|||
.filter(({ id }) => { |
|||
return !tags.some(({ id: tagId }) => { |
|||
return tagId === id; |
|||
}); |
|||
}) |
|||
.forEach(({ id, name }) => { |
|||
tags.push({ id, name }); |
|||
}); |
|||
} |
|||
|
|||
const activities: Activity[] = []; |
|||
|
|||
for (const activity of activitiesExtendedWithErrors) { |
|||
const accountId = activity.accountId; |
|||
const comment = activity.comment; |
|||
const currency = activity.currency; |
|||
const date = activity.date; |
|||
const error = activity.error; |
|||
const fee = activity.fee; |
|||
const quantity = activity.quantity; |
|||
const SymbolProfile = activity.SymbolProfile; |
|||
const tagIds = activity.tagIds ?? []; |
|||
const type = activity.type; |
|||
const unitPrice = activity.unitPrice; |
|||
|
|||
const assetProfile = assetProfiles[ |
|||
getAssetProfileIdentifier({ |
|||
dataSource: SymbolProfile.dataSource, |
|||
symbol: SymbolProfile.symbol |
|||
}) |
|||
] ?? { |
|||
dataSource: SymbolProfile.dataSource, |
|||
symbol: SymbolProfile.symbol |
|||
}; |
|||
const { |
|||
assetClass, |
|||
assetSubClass, |
|||
countries, |
|||
createdAt, |
|||
cusip, |
|||
dataSource, |
|||
figi, |
|||
figiComposite, |
|||
figiShareClass, |
|||
holdings, |
|||
id, |
|||
isActive, |
|||
isin, |
|||
name, |
|||
scraperConfiguration, |
|||
sectors, |
|||
symbol, |
|||
symbolMapping, |
|||
url, |
|||
updatedAt |
|||
} = assetProfile; |
|||
const validatedAccount = accounts.find(({ id }) => { |
|||
return id === accountId; |
|||
}); |
|||
const validatedTags = tags.filter(({ id: tagId }) => { |
|||
return tagIds.some((activityTagId) => { |
|||
return activityTagId === tagId; |
|||
}); |
|||
}); |
|||
|
|||
let order: |
|||
| OrderWithAccount |
|||
| (Omit<OrderWithAccount, 'account' | 'tags'> & { |
|||
account?: { id: string; name: string }; |
|||
tags?: { id: string; name: string }[]; |
|||
}); |
|||
|
|||
if (isDryRun) { |
|||
order = { |
|||
comment, |
|||
currency, |
|||
date, |
|||
fee, |
|||
quantity, |
|||
type, |
|||
unitPrice, |
|||
account: validatedAccount, |
|||
accountId: validatedAccount?.id, |
|||
accountUserId: undefined, |
|||
createdAt: new Date(), |
|||
id: randomUUID(), |
|||
isDraft: isAfter(date, endOfToday()), |
|||
SymbolProfile: { |
|||
assetClass, |
|||
assetSubClass, |
|||
countries, |
|||
createdAt, |
|||
cusip, |
|||
dataSource, |
|||
figi, |
|||
figiComposite, |
|||
figiShareClass, |
|||
holdings, |
|||
id, |
|||
isActive, |
|||
isin, |
|||
name, |
|||
scraperConfiguration, |
|||
sectors, |
|||
symbol, |
|||
symbolMapping, |
|||
updatedAt, |
|||
url, |
|||
comment: assetProfile.comment, |
|||
currency: assetProfile.currency, |
|||
userId: dataSource === 'MANUAL' ? user.id : undefined |
|||
}, |
|||
symbolProfileId: undefined, |
|||
tags: validatedTags, |
|||
updatedAt: new Date(), |
|||
userId: user.id |
|||
}; |
|||
} else { |
|||
if (error) { |
|||
continue; |
|||
} |
|||
|
|||
order = await this.orderService.createOrder({ |
|||
comment, |
|||
currency, |
|||
date, |
|||
fee, |
|||
quantity, |
|||
type, |
|||
unitPrice, |
|||
accountId: validatedAccount?.id, |
|||
SymbolProfile: { |
|||
connectOrCreate: { |
|||
create: { |
|||
dataSource, |
|||
name, |
|||
symbol, |
|||
currency: assetProfile.currency, |
|||
userId: dataSource === 'MANUAL' ? user.id : undefined |
|||
}, |
|||
where: { |
|||
dataSource_symbol: { |
|||
dataSource, |
|||
symbol |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
tags: validatedTags.map(({ id }) => { |
|||
return { id }; |
|||
}), |
|||
updateAccountBalance: false, |
|||
user: { connect: { id: user.id } }, |
|||
userId: user.id |
|||
}); |
|||
|
|||
if (order.SymbolProfile?.symbol) { |
|||
// Update symbol that may have been assigned in createOrder()
|
|||
assetProfile.symbol = order.SymbolProfile.symbol; |
|||
} |
|||
} |
|||
|
|||
const value = new Big(quantity).mul(unitPrice).toNumber(); |
|||
|
|||
const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate( |
|||
value, |
|||
currency ?? assetProfile.currency, |
|||
userCurrency, |
|||
date |
|||
); |
|||
|
|||
activities.push({ |
|||
...order, |
|||
error, |
|||
value, |
|||
valueInBaseCurrency: await valueInBaseCurrency, |
|||
// @ts-ignore
|
|||
SymbolProfile: assetProfile |
|||
}); |
|||
} |
|||
|
|||
activities.sort((activity1, activity2) => { |
|||
return Number(activity1.date) - Number(activity2.date); |
|||
}); |
|||
|
|||
if (!isDryRun) { |
|||
// Gather symbol data in the background, if not dry run
|
|||
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => { |
|||
return getAssetProfileIdentifier({ |
|||
dataSource: SymbolProfile.dataSource, |
|||
symbol: SymbolProfile.symbol |
|||
}); |
|||
}); |
|||
|
|||
this.dataGatheringService.gatherSymbols({ |
|||
dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => { |
|||
return { |
|||
date, |
|||
dataSource: SymbolProfile.dataSource, |
|||
symbol: SymbolProfile.symbol |
|||
}; |
|||
}), |
|||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH |
|||
}); |
|||
} |
|||
|
|||
return activities; |
|||
} |
|||
|
|||
private async extendActivitiesWithErrors({ |
|||
activitiesDto, |
|||
userCurrency, |
|||
userId |
|||
}: { |
|||
activitiesDto: Partial<CreateOrderDto>[]; |
|||
userCurrency: string; |
|||
userId: string; |
|||
}): Promise<Partial<Activity>[]> { |
|||
const { activities: existingActivities } = |
|||
await this.orderService.getOrders({ |
|||
userCurrency, |
|||
userId, |
|||
includeDrafts: true, |
|||
withExcludedAccountsAndActivities: true |
|||
}); |
|||
|
|||
return activitiesDto.map( |
|||
({ |
|||
accountId, |
|||
comment, |
|||
currency, |
|||
dataSource, |
|||
date: dateString, |
|||
fee, |
|||
quantity, |
|||
symbol, |
|||
tags, |
|||
type, |
|||
unitPrice |
|||
}) => { |
|||
const date = parseISO(dateString); |
|||
const isDuplicate = existingActivities.some((activity) => { |
|||
return ( |
|||
activity.accountId === accountId && |
|||
activity.comment === comment && |
|||
(activity.currency === currency || |
|||
activity.SymbolProfile.currency === currency) && |
|||
activity.SymbolProfile.dataSource === dataSource && |
|||
isSameSecond(activity.date, date) && |
|||
activity.fee === fee && |
|||
activity.quantity === quantity && |
|||
activity.SymbolProfile.symbol === symbol && |
|||
activity.type === type && |
|||
activity.unitPrice === unitPrice |
|||
); |
|||
}); |
|||
|
|||
const error: ActivityError = isDuplicate |
|||
? { code: 'IS_DUPLICATE' } |
|||
: undefined; |
|||
|
|||
return { |
|||
accountId, |
|||
comment, |
|||
currency, |
|||
date, |
|||
error, |
|||
fee, |
|||
quantity, |
|||
type, |
|||
unitPrice, |
|||
SymbolProfile: { |
|||
dataSource, |
|||
symbol, |
|||
activitiesCount: undefined, |
|||
assetClass: undefined, |
|||
assetSubClass: undefined, |
|||
countries: undefined, |
|||
createdAt: undefined, |
|||
currency: undefined, |
|||
holdings: undefined, |
|||
id: undefined, |
|||
isActive: true, |
|||
sectors: undefined, |
|||
updatedAt: undefined |
|||
}, |
|||
tagIds: tags |
|||
}; |
|||
} |
|||
); |
|||
} |
|||
|
|||
private isUniqueAccount(accounts: AccountWithValue[]) { |
|||
const uniqueAccountIds = new Set<string>(); |
|||
|
|||
for (const { id } of accounts) { |
|||
uniqueAccountIds.add(id); |
|||
} |
|||
|
|||
return uniqueAccountIds.size === 1; |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { InfoResponse } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Controller, Get, UseInterceptors } from '@nestjs/common'; |
|||
|
|||
import { InfoService } from './info.service'; |
|||
|
|||
@Controller('info') |
|||
export class InfoController { |
|||
public constructor(private readonly infoService: InfoService) {} |
|||
|
|||
@Get() |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getInfo(): Promise<InfoResponse> { |
|||
return this.infoService.get(); |
|||
} |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; |
|||
import { UserModule } from '@ghostfolio/api/app/user/user.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; |
|||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { PropertyModule } from '@ghostfolio/api/services/property/property.module'; |
|||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
import { JwtModule } from '@nestjs/jwt'; |
|||
|
|||
import { InfoController } from './info.controller'; |
|||
import { InfoService } from './info.service'; |
|||
|
|||
@Module({ |
|||
controllers: [InfoController], |
|||
imports: [ |
|||
BenchmarkModule, |
|||
ConfigurationModule, |
|||
DataGatheringModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
JwtModule.register({ |
|||
secret: process.env.JWT_SECRET_KEY, |
|||
signOptions: { expiresIn: '30 days' } |
|||
}), |
|||
PlatformModule, |
|||
PropertyModule, |
|||
RedisCacheModule, |
|||
SubscriptionModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInResponseModule, |
|||
UserModule |
|||
], |
|||
providers: [InfoService] |
|||
}) |
|||
export class InfoModule {} |
|||
@ -0,0 +1,331 @@ |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; |
|||
import { UserService } from '@ghostfolio/api/app/user/user.service'; |
|||
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; |
|||
import { |
|||
DEFAULT_CURRENCY, |
|||
HEADER_KEY_TOKEN, |
|||
PROPERTY_BETTER_UPTIME_MONITOR_ID, |
|||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, |
|||
PROPERTY_DEMO_USER_ID, |
|||
PROPERTY_IS_READ_ONLY_MODE, |
|||
PROPERTY_SLACK_COMMUNITY_USERS, |
|||
ghostfolioFearAndGreedIndexDataSourceStocks |
|||
} from '@ghostfolio/common/config'; |
|||
import { |
|||
DATE_FORMAT, |
|||
encodeDataSource, |
|||
extractNumberFromString |
|||
} from '@ghostfolio/common/helper'; |
|||
import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { Injectable, Logger } from '@nestjs/common'; |
|||
import { JwtService } from '@nestjs/jwt'; |
|||
import * as cheerio from 'cheerio'; |
|||
import { format, subDays } from 'date-fns'; |
|||
|
|||
@Injectable() |
|||
export class InfoService { |
|||
private static CACHE_KEY_STATISTICS = 'STATISTICS'; |
|||
|
|||
public constructor( |
|||
private readonly benchmarkService: BenchmarkService, |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly jwtService: JwtService, |
|||
private readonly propertyService: PropertyService, |
|||
private readonly redisCacheService: RedisCacheService, |
|||
private readonly subscriptionService: SubscriptionService, |
|||
private readonly userService: UserService |
|||
) {} |
|||
|
|||
public async get(): Promise<InfoItem> { |
|||
const info: Partial<InfoItem> = {}; |
|||
let isReadOnlyMode: boolean; |
|||
|
|||
const globalPermissions: string[] = []; |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_AUTH_GOOGLE')) { |
|||
globalPermissions.push(permissions.enableAuthGoogle); |
|||
} |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { |
|||
globalPermissions.push(permissions.enableAuthOidc); |
|||
} |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_AUTH_TOKEN')) { |
|||
globalPermissions.push(permissions.enableAuthToken); |
|||
} |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { |
|||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
|||
info.fearAndGreedDataSource = encodeDataSource( |
|||
ghostfolioFearAndGreedIndexDataSourceStocks |
|||
); |
|||
} else { |
|||
info.fearAndGreedDataSource = |
|||
ghostfolioFearAndGreedIndexDataSourceStocks; |
|||
} |
|||
|
|||
globalPermissions.push(permissions.enableFearAndGreedIndex); |
|||
} |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { |
|||
isReadOnlyMode = await this.propertyService.getByKey<boolean>( |
|||
PROPERTY_IS_READ_ONLY_MODE |
|||
); |
|||
} |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) { |
|||
globalPermissions.push(permissions.enableStatistics); |
|||
} |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { |
|||
globalPermissions.push(permissions.enableSubscription); |
|||
|
|||
info.countriesOfSubscribers = |
|||
(await this.propertyService.getByKey<string[]>( |
|||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS |
|||
)) ?? []; |
|||
} |
|||
|
|||
if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) { |
|||
globalPermissions.push(permissions.enableSystemMessage); |
|||
} |
|||
|
|||
const [ |
|||
benchmarks, |
|||
demoAuthToken, |
|||
isUserSignupEnabled, |
|||
statistics, |
|||
subscriptionOffer |
|||
] = await Promise.all([ |
|||
this.benchmarkService.getBenchmarkAssetProfiles(), |
|||
this.getDemoAuthToken(), |
|||
this.propertyService.isUserSignupEnabled(), |
|||
this.getStatistics(), |
|||
this.subscriptionService.getSubscriptionOffer({ key: 'default' }) |
|||
]); |
|||
|
|||
if (isUserSignupEnabled) { |
|||
globalPermissions.push(permissions.createUserAccount); |
|||
} |
|||
|
|||
return { |
|||
...info, |
|||
benchmarks, |
|||
demoAuthToken, |
|||
globalPermissions, |
|||
isReadOnlyMode, |
|||
statistics, |
|||
subscriptionOffer, |
|||
baseCurrency: DEFAULT_CURRENCY, |
|||
currencies: this.exchangeRateDataService.getCurrencies() |
|||
}; |
|||
} |
|||
|
|||
private async countActiveUsers(aDays: number) { |
|||
return this.userService.count({ |
|||
where: { |
|||
AND: [ |
|||
{ |
|||
NOT: { |
|||
analytics: null |
|||
} |
|||
}, |
|||
{ |
|||
analytics: { |
|||
lastRequestAt: { |
|||
gt: subDays(new Date(), aDays) |
|||
} |
|||
} |
|||
} |
|||
] |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private async countDockerHubPulls(): Promise<number> { |
|||
try { |
|||
const { pull_count } = (await fetch( |
|||
'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', |
|||
{ |
|||
headers: { 'User-Agent': 'request' }, |
|||
signal: AbortSignal.timeout( |
|||
this.configurationService.get('REQUEST_TIMEOUT') |
|||
) |
|||
} |
|||
).then((res) => res.json())) as { pull_count: number }; |
|||
|
|||
return pull_count; |
|||
} catch (error) { |
|||
Logger.error(error, 'InfoService - DockerHub'); |
|||
|
|||
return undefined; |
|||
} |
|||
} |
|||
|
|||
private async countGitHubContributors(): Promise<number> { |
|||
try { |
|||
const body = await fetch('https://github.com/ghostfolio/ghostfolio', { |
|||
signal: AbortSignal.timeout( |
|||
this.configurationService.get('REQUEST_TIMEOUT') |
|||
) |
|||
}).then((res) => res.text()); |
|||
|
|||
const $ = cheerio.load(body); |
|||
|
|||
return extractNumberFromString({ |
|||
value: $( |
|||
'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter' |
|||
).text() |
|||
}); |
|||
} catch (error) { |
|||
Logger.error(error, 'InfoService - GitHub'); |
|||
|
|||
return undefined; |
|||
} |
|||
} |
|||
|
|||
private async countGitHubStargazers(): Promise<number> { |
|||
try { |
|||
const { stargazers_count } = (await fetch( |
|||
'https://api.github.com/repos/ghostfolio/ghostfolio', |
|||
{ |
|||
headers: { 'User-Agent': 'request' }, |
|||
signal: AbortSignal.timeout( |
|||
this.configurationService.get('REQUEST_TIMEOUT') |
|||
) |
|||
} |
|||
).then((res) => res.json())) as { stargazers_count: number }; |
|||
|
|||
return stargazers_count; |
|||
} catch (error) { |
|||
Logger.error(error, 'InfoService - GitHub'); |
|||
|
|||
return undefined; |
|||
} |
|||
} |
|||
|
|||
private async countNewUsers(aDays: number) { |
|||
return this.userService.count({ |
|||
where: { |
|||
AND: [ |
|||
{ |
|||
NOT: { |
|||
analytics: null |
|||
} |
|||
}, |
|||
{ |
|||
createdAt: { |
|||
gt: subDays(new Date(), aDays) |
|||
} |
|||
} |
|||
] |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private async countSlackCommunityUsers() { |
|||
return await this.propertyService.getByKey<string>( |
|||
PROPERTY_SLACK_COMMUNITY_USERS |
|||
); |
|||
} |
|||
|
|||
private async getDemoAuthToken() { |
|||
const demoUserId = await this.propertyService.getByKey<string>( |
|||
PROPERTY_DEMO_USER_ID |
|||
); |
|||
|
|||
if (demoUserId) { |
|||
return this.jwtService.sign({ |
|||
id: demoUserId |
|||
}); |
|||
} |
|||
|
|||
return undefined; |
|||
} |
|||
|
|||
private async getStatistics() { |
|||
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) { |
|||
return undefined; |
|||
} |
|||
|
|||
let statistics: Statistics; |
|||
|
|||
try { |
|||
statistics = JSON.parse( |
|||
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS) |
|||
); |
|||
|
|||
if (statistics) { |
|||
return statistics; |
|||
} |
|||
} catch {} |
|||
|
|||
const activeUsers1d = await this.countActiveUsers(1); |
|||
const activeUsers30d = await this.countActiveUsers(30); |
|||
const newUsers30d = await this.countNewUsers(30); |
|||
|
|||
const dockerHubPulls = await this.countDockerHubPulls(); |
|||
const gitHubContributors = await this.countGitHubContributors(); |
|||
const gitHubStargazers = await this.countGitHubStargazers(); |
|||
const slackCommunityUsers = await this.countSlackCommunityUsers(); |
|||
const uptime = await this.getUptime(); |
|||
|
|||
statistics = { |
|||
activeUsers1d, |
|||
activeUsers30d, |
|||
dockerHubPulls, |
|||
gitHubContributors, |
|||
gitHubStargazers, |
|||
newUsers30d, |
|||
slackCommunityUsers, |
|||
uptime |
|||
}; |
|||
|
|||
await this.redisCacheService.set( |
|||
InfoService.CACHE_KEY_STATISTICS, |
|||
JSON.stringify(statistics) |
|||
); |
|||
|
|||
return statistics; |
|||
} |
|||
|
|||
private async getUptime(): Promise<number> { |
|||
{ |
|||
try { |
|||
const monitorId = await this.propertyService.getByKey<string>( |
|||
PROPERTY_BETTER_UPTIME_MONITOR_ID |
|||
); |
|||
|
|||
const { data } = await fetch( |
|||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( |
|||
subDays(new Date(), 90), |
|||
DATE_FORMAT |
|||
)}&to${format(new Date(), DATE_FORMAT)}`,
|
|||
{ |
|||
headers: { |
|||
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( |
|||
'API_KEY_BETTER_UPTIME' |
|||
)}` |
|||
}, |
|||
signal: AbortSignal.timeout( |
|||
this.configurationService.get('REQUEST_TIMEOUT') |
|||
) |
|||
} |
|||
).then((res) => res.json()); |
|||
|
|||
return data.attributes.availability / 100; |
|||
} catch (error) { |
|||
Logger.error(error, 'InfoService - Better Stack'); |
|||
|
|||
return undefined; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/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, type } = |
|||
await this.logoService.getLogoByDataSourceAndSymbol({ |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
|
|||
response.contentType(type); |
|||
response.send(buffer); |
|||
} catch { |
|||
response.status(HttpStatus.NOT_FOUND).send(); |
|||
} |
|||
} |
|||
|
|||
@Get() |
|||
public async getLogoByUrl( |
|||
@Query('url') url: string, |
|||
@Res() response: Response |
|||
) { |
|||
try { |
|||
const { buffer, type } = await this.logoService.getLogoByUrl(url); |
|||
|
|||
response.contentType(type); |
|||
response.send(buffer); |
|||
} catch { |
|||
response.status(HttpStatus.NOT_FOUND).send(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
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, |
|||
TransformDataSourceInRequestModule |
|||
], |
|||
providers: [LogoService] |
|||
}) |
|||
export class LogoModule {} |
|||
@ -0,0 +1,63 @@ |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { HttpException, Injectable } from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
@Injectable() |
|||
export class LogoService { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
public async getLogoByDataSourceAndSymbol({ |
|||
dataSource, |
|||
symbol |
|||
}: AssetProfileIdentifier) { |
|||
if (!DataSource[dataSource]) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ |
|||
{ dataSource, symbol } |
|||
]); |
|||
|
|||
if (!assetProfile?.url) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return this.getBuffer(assetProfile.url); |
|||
} |
|||
|
|||
public getLogoByUrl(aUrl: string) { |
|||
return this.getBuffer(aUrl); |
|||
} |
|||
|
|||
private async getBuffer(aUrl: string) { |
|||
const blob = await fetch( |
|||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, |
|||
{ |
|||
headers: { 'User-Agent': 'request' }, |
|||
signal: AbortSignal.timeout( |
|||
this.configurationService.get('REQUEST_TIMEOUT') |
|||
) |
|||
} |
|||
).then((res) => res.blob()); |
|||
|
|||
return { |
|||
buffer: await blob.arrayBuffer().then((arrayBuffer) => { |
|||
return Buffer.from(arrayBuffer); |
|||
}), |
|||
type: blob.type |
|||
}; |
|||
} |
|||
} |
|||
@ -0,0 +1,337 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { ApiService } from '@ghostfolio/api/services/api/api.service'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
|||
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; |
|||
import { |
|||
DATA_GATHERING_QUEUE_PRIORITY_HIGH, |
|||
HEADER_KEY_IMPERSONATION |
|||
} from '@ghostfolio/common/config'; |
|||
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; |
|||
import { |
|||
ActivitiesResponse, |
|||
ActivityResponse |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
Headers, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
Put, |
|||
Query, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { Order as OrderModel, Prisma } from '@prisma/client'; |
|||
import { parseISO } from 'date-fns'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { OrderService } from './order.service'; |
|||
|
|||
@Controller('order') |
|||
export class OrderController { |
|||
public constructor( |
|||
private readonly apiService: ApiService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
private readonly impersonationService: ImpersonationService, |
|||
private readonly orderService: OrderService, |
|||
@Inject(REQUEST) private readonly request: RequestWithUser |
|||
) {} |
|||
|
|||
@Delete() |
|||
@HasPermission(permissions.deleteOrder) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async deleteOrders( |
|||
@Query('accounts') filterByAccounts?: string, |
|||
@Query('assetClasses') filterByAssetClasses?: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('symbol') filterBySymbol?: string, |
|||
@Query('tags') filterByTags?: string |
|||
): Promise<number> { |
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAccounts, |
|||
filterByAssetClasses, |
|||
filterByDataSource, |
|||
filterBySymbol, |
|||
filterByTags |
|||
}); |
|||
|
|||
return this.orderService.deleteOrders({ |
|||
filters, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Delete(':id') |
|||
@HasPermission(permissions.deleteOrder) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> { |
|||
const order = await this.orderService.order({ |
|||
id, |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
if (!order) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
return this.orderService.deleteOrder({ |
|||
id |
|||
}); |
|||
} |
|||
|
|||
@Get() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(RedactValuesInResponseInterceptor) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getAllOrders( |
|||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, |
|||
@Query('accounts') filterByAccounts?: string, |
|||
@Query('assetClasses') filterByAssetClasses?: string, |
|||
@Query('dataSource') filterByDataSource?: string, |
|||
@Query('range') dateRange?: DateRange, |
|||
@Query('skip') skip?: number, |
|||
@Query('sortColumn') sortColumn?: string, |
|||
@Query('sortDirection') sortDirection?: Prisma.SortOrder, |
|||
@Query('symbol') filterBySymbol?: string, |
|||
@Query('tags') filterByTags?: string, |
|||
@Query('take') take?: number |
|||
): Promise<ActivitiesResponse> { |
|||
let endDate: Date; |
|||
let startDate: Date; |
|||
|
|||
if (dateRange) { |
|||
({ endDate, startDate } = getIntervalFromDateRange(dateRange)); |
|||
} |
|||
|
|||
const filters = this.apiService.buildFiltersFromQueryParams({ |
|||
filterByAccounts, |
|||
filterByAssetClasses, |
|||
filterByDataSource, |
|||
filterBySymbol, |
|||
filterByTags |
|||
}); |
|||
|
|||
const impersonationUserId = |
|||
await this.impersonationService.validateImpersonationId(impersonationId); |
|||
const userCurrency = this.request.user.settings.settings.baseCurrency; |
|||
|
|||
const { activities, count } = await this.orderService.getOrders({ |
|||
endDate, |
|||
filters, |
|||
sortColumn, |
|||
sortDirection, |
|||
startDate, |
|||
userCurrency, |
|||
includeDrafts: true, |
|||
skip: isNaN(skip) ? undefined : skip, |
|||
take: isNaN(take) ? undefined : take, |
|||
userId: impersonationUserId || this.request.user.id, |
|||
withExcludedAccountsAndActivities: true |
|||
}); |
|||
|
|||
return { activities, count }; |
|||
} |
|||
|
|||
@Get(':id') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(RedactValuesInResponseInterceptor) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getOrderById( |
|||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, |
|||
@Param('id') id: string |
|||
): Promise<ActivityResponse> { |
|||
const impersonationUserId = |
|||
await this.impersonationService.validateImpersonationId(impersonationId); |
|||
const userCurrency = this.request.user.settings.settings.baseCurrency; |
|||
|
|||
const { activities } = await this.orderService.getOrders({ |
|||
userCurrency, |
|||
includeDrafts: true, |
|||
userId: impersonationUserId || this.request.user.id, |
|||
withExcludedAccountsAndActivities: true |
|||
}); |
|||
|
|||
const activity = activities.find((activity) => { |
|||
return activity.id === id; |
|||
}); |
|||
|
|||
if (!activity) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return activity; |
|||
} |
|||
|
|||
@HasPermission(permissions.createOrder) |
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> { |
|||
try { |
|||
await this.dataProviderService.validateActivities({ |
|||
activitiesDto: [ |
|||
{ |
|||
currency: data.currency, |
|||
dataSource: data.dataSource, |
|||
symbol: data.symbol, |
|||
type: data.type |
|||
} |
|||
], |
|||
maxActivitiesToImport: 1, |
|||
user: this.request.user |
|||
}); |
|||
} catch (error) { |
|||
throw new HttpException( |
|||
{ |
|||
error: getReasonPhrase(StatusCodes.BAD_REQUEST), |
|||
message: [error.message] |
|||
}, |
|||
StatusCodes.BAD_REQUEST |
|||
); |
|||
} |
|||
|
|||
const currency = data.currency; |
|||
const customCurrency = data.customCurrency; |
|||
const dataSource = data.dataSource; |
|||
|
|||
if (customCurrency) { |
|||
data.currency = customCurrency; |
|||
|
|||
delete data.customCurrency; |
|||
} |
|||
|
|||
delete data.dataSource; |
|||
|
|||
const order = await this.orderService.createOrder({ |
|||
...data, |
|||
date: parseISO(data.date), |
|||
SymbolProfile: { |
|||
connectOrCreate: { |
|||
create: { |
|||
currency, |
|||
dataSource, |
|||
symbol: data.symbol |
|||
}, |
|||
where: { |
|||
dataSource_symbol: { |
|||
dataSource, |
|||
symbol: data.symbol |
|||
} |
|||
} |
|||
} |
|||
}, |
|||
tags: data.tags?.map((id) => { |
|||
return { id }; |
|||
}), |
|||
user: { connect: { id: this.request.user.id } }, |
|||
userId: this.request.user.id |
|||
}); |
|||
|
|||
if (dataSource && !order.isDraft) { |
|||
// Gather symbol data in the background, if data source is set
|
|||
// (not MANUAL) and not draft
|
|||
this.dataGatheringService.gatherSymbols({ |
|||
dataGatheringItems: [ |
|||
{ |
|||
dataSource, |
|||
date: order.date, |
|||
symbol: data.symbol |
|||
} |
|||
], |
|||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH |
|||
}); |
|||
} |
|||
|
|||
return order; |
|||
} |
|||
|
|||
@HasPermission(permissions.updateOrder) |
|||
@Put(':id') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { |
|||
const originalOrder = await this.orderService.order({ |
|||
id |
|||
}); |
|||
|
|||
if (!originalOrder || originalOrder.userId !== this.request.user.id) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.FORBIDDEN), |
|||
StatusCodes.FORBIDDEN |
|||
); |
|||
} |
|||
|
|||
const date = parseISO(data.date); |
|||
|
|||
const accountId = data.accountId; |
|||
const customCurrency = data.customCurrency; |
|||
const dataSource = data.dataSource; |
|||
|
|||
delete data.accountId; |
|||
|
|||
if (customCurrency) { |
|||
data.currency = customCurrency; |
|||
|
|||
delete data.customCurrency; |
|||
} |
|||
|
|||
delete data.dataSource; |
|||
|
|||
return this.orderService.updateOrder({ |
|||
data: { |
|||
...data, |
|||
date, |
|||
account: { |
|||
connect: { |
|||
id_userId: { id: accountId, userId: this.request.user.id } |
|||
} |
|||
}, |
|||
SymbolProfile: { |
|||
connect: { |
|||
dataSource_symbol: { |
|||
dataSource, |
|||
symbol: data.symbol |
|||
} |
|||
}, |
|||
update: { |
|||
assetClass: data.assetClass, |
|||
assetSubClass: data.assetSubClass, |
|||
name: data.symbol |
|||
} |
|||
}, |
|||
tags: data.tags?.map((id) => { |
|||
return { id }; |
|||
}), |
|||
user: { connect: { id: this.request.user.id } } |
|||
}, |
|||
where: { |
|||
id |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,40 @@ |
|||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; |
|||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; |
|||
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { ApiModule } from '@ghostfolio/api/services/api/api.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; |
|||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { OrderController } from './order.controller'; |
|||
import { OrderService } from './order.service'; |
|||
|
|||
@Module({ |
|||
controllers: [OrderController], |
|||
exports: [OrderService], |
|||
imports: [ |
|||
ApiModule, |
|||
CacheModule, |
|||
DataGatheringModule, |
|||
DataProviderModule, |
|||
ExchangeRateDataModule, |
|||
ImpersonationModule, |
|||
PrismaModule, |
|||
RedactValuesInResponseModule, |
|||
RedisCacheModule, |
|||
SymbolProfileModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule |
|||
], |
|||
providers: [AccountBalanceService, AccountService, OrderService] |
|||
}) |
|||
export class OrderModule {} |
|||
@ -0,0 +1,925 @@ |
|||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; |
|||
import { AccountService } from '@ghostfolio/api/app/account/account.service'; |
|||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; |
|||
import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; |
|||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; |
|||
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; |
|||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; |
|||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; |
|||
import { |
|||
DATA_GATHERING_QUEUE_PRIORITY_HIGH, |
|||
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, |
|||
GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, |
|||
ghostfolioPrefix, |
|||
TAG_ID_EXCLUDE_FROM_ANALYSIS |
|||
} from '@ghostfolio/common/config'; |
|||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; |
|||
import { |
|||
ActivitiesResponse, |
|||
Activity, |
|||
AssetProfileIdentifier, |
|||
EnhancedSymbolProfile, |
|||
Filter |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { OrderWithAccount } from '@ghostfolio/common/types'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
import { EventEmitter2 } from '@nestjs/event-emitter'; |
|||
import { |
|||
AssetClass, |
|||
AssetSubClass, |
|||
DataSource, |
|||
Order, |
|||
Prisma, |
|||
Tag, |
|||
Type as ActivityType |
|||
} from '@prisma/client'; |
|||
import { Big } from 'big.js'; |
|||
import { isUUID } from 'class-validator'; |
|||
import { endOfToday, isAfter } from 'date-fns'; |
|||
import { groupBy, uniqBy } from 'lodash'; |
|||
import { randomUUID } from 'node:crypto'; |
|||
|
|||
@Injectable() |
|||
export class OrderService { |
|||
public constructor( |
|||
private readonly accountBalanceService: AccountBalanceService, |
|||
private readonly accountService: AccountService, |
|||
private readonly dataGatheringService: DataGatheringService, |
|||
private readonly dataProviderService: DataProviderService, |
|||
private readonly eventEmitter: EventEmitter2, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly prismaService: PrismaService, |
|||
private readonly symbolProfileService: SymbolProfileService |
|||
) {} |
|||
|
|||
public async assignTags({ |
|||
dataSource, |
|||
symbol, |
|||
tags, |
|||
userId |
|||
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { |
|||
const orders = await this.prismaService.order.findMany({ |
|||
where: { |
|||
userId, |
|||
SymbolProfile: { |
|||
dataSource, |
|||
symbol |
|||
} |
|||
} |
|||
}); |
|||
|
|||
await Promise.all( |
|||
orders.map(({ id }) => |
|||
this.prismaService.order.update({ |
|||
data: { |
|||
tags: { |
|||
// The set operation replaces all existing connections with the provided ones
|
|||
set: tags.map((tag) => { |
|||
return { id: tag.id }; |
|||
}) |
|||
} |
|||
}, |
|||
where: { id } |
|||
}) |
|||
) |
|||
); |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ |
|||
userId |
|||
}) |
|||
); |
|||
} |
|||
|
|||
public async createOrder( |
|||
data: Prisma.OrderCreateInput & { |
|||
accountId?: string; |
|||
assetClass?: AssetClass; |
|||
assetSubClass?: AssetSubClass; |
|||
currency?: string; |
|||
symbol?: string; |
|||
tags?: { id: string }[]; |
|||
updateAccountBalance?: boolean; |
|||
userId: string; |
|||
} |
|||
): Promise<Order> { |
|||
let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput; |
|||
|
|||
if (data.accountId) { |
|||
account = { |
|||
connect: { |
|||
id_userId: { |
|||
userId: data.userId, |
|||
id: data.accountId |
|||
} |
|||
} |
|||
}; |
|||
} |
|||
|
|||
const accountId = data.accountId; |
|||
const tags = data.tags ?? []; |
|||
const updateAccountBalance = data.updateAccountBalance ?? false; |
|||
const userId = data.userId; |
|||
|
|||
if ( |
|||
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || |
|||
(data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' && |
|||
data.type === 'BUY') |
|||
) { |
|||
const assetClass = data.assetClass; |
|||
const assetSubClass = data.assetSubClass; |
|||
const dataSource: DataSource = 'MANUAL'; |
|||
|
|||
let name = data.SymbolProfile.connectOrCreate.create.name; |
|||
let symbol: string; |
|||
|
|||
if ( |
|||
data.SymbolProfile.connectOrCreate.create.symbol.startsWith( |
|||
`${ghostfolioPrefix}_` |
|||
) || |
|||
isUUID(data.SymbolProfile.connectOrCreate.create.symbol) |
|||
) { |
|||
// Connect custom asset profile (clone)
|
|||
symbol = data.SymbolProfile.connectOrCreate.create.symbol; |
|||
} else { |
|||
// Create custom asset profile
|
|||
name = name ?? data.SymbolProfile.connectOrCreate.create.symbol; |
|||
symbol = randomUUID(); |
|||
} |
|||
|
|||
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; |
|||
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; |
|||
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; |
|||
data.SymbolProfile.connectOrCreate.create.name = name; |
|||
data.SymbolProfile.connectOrCreate.create.symbol = symbol; |
|||
data.SymbolProfile.connectOrCreate.create.userId = userId; |
|||
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = { |
|||
dataSource, |
|||
symbol |
|||
}; |
|||
} |
|||
|
|||
if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') { |
|||
this.dataGatheringService.addJobToQueue({ |
|||
data: { |
|||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, |
|||
symbol: data.SymbolProfile.connectOrCreate.create.symbol |
|||
}, |
|||
name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, |
|||
opts: { |
|||
...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, |
|||
jobId: getAssetProfileIdentifier({ |
|||
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, |
|||
symbol: data.SymbolProfile.connectOrCreate.create.symbol |
|||
}), |
|||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH |
|||
} |
|||
}); |
|||
} |
|||
|
|||
delete data.accountId; |
|||
delete data.assetClass; |
|||
delete data.assetSubClass; |
|||
|
|||
if (!data.comment) { |
|||
delete data.comment; |
|||
} |
|||
|
|||
delete data.symbol; |
|||
delete data.tags; |
|||
delete data.updateAccountBalance; |
|||
delete data.userId; |
|||
|
|||
const orderData: Prisma.OrderCreateInput = data; |
|||
|
|||
const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) |
|||
? false |
|||
: isAfter(data.date as Date, endOfToday()); |
|||
|
|||
const order = await this.prismaService.order.create({ |
|||
data: { |
|||
...orderData, |
|||
account, |
|||
isDraft, |
|||
tags: { |
|||
connect: tags |
|||
} |
|||
}, |
|||
include: { SymbolProfile: true } |
|||
}); |
|||
|
|||
if (updateAccountBalance === true) { |
|||
let amount = new Big(data.unitPrice) |
|||
.mul(data.quantity) |
|||
.plus(data.fee) |
|||
.toNumber(); |
|||
|
|||
if (['BUY', 'FEE'].includes(data.type)) { |
|||
amount = new Big(amount).mul(-1).toNumber(); |
|||
} |
|||
|
|||
await this.accountService.updateAccountBalance({ |
|||
accountId, |
|||
amount, |
|||
userId, |
|||
currency: data.SymbolProfile.connectOrCreate.create.currency, |
|||
date: data.date as Date |
|||
}); |
|||
} |
|||
|
|||
this.eventEmitter.emit( |
|||
AssetProfileChangedEvent.getName(), |
|||
new AssetProfileChangedEvent({ |
|||
currency: order.SymbolProfile.currency, |
|||
dataSource: order.SymbolProfile.dataSource, |
|||
symbol: order.SymbolProfile.symbol |
|||
}) |
|||
); |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ |
|||
userId: order.userId |
|||
}) |
|||
); |
|||
|
|||
return order; |
|||
} |
|||
|
|||
public async deleteOrder( |
|||
where: Prisma.OrderWhereUniqueInput |
|||
): Promise<Order> { |
|||
const order = await this.prismaService.order.delete({ |
|||
where |
|||
}); |
|||
|
|||
const [symbolProfile] = |
|||
await this.symbolProfileService.getSymbolProfilesByIds([ |
|||
order.symbolProfileId |
|||
]); |
|||
|
|||
if (symbolProfile.activitiesCount === 0) { |
|||
await this.symbolProfileService.deleteById(order.symbolProfileId); |
|||
} |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ |
|||
userId: order.userId |
|||
}) |
|||
); |
|||
|
|||
return order; |
|||
} |
|||
|
|||
public async deleteOrders({ |
|||
filters, |
|||
userId |
|||
}: { |
|||
filters?: Filter[]; |
|||
userId: string; |
|||
}): Promise<number> { |
|||
const { activities } = await this.getOrders({ |
|||
filters, |
|||
userId, |
|||
includeDrafts: true, |
|||
userCurrency: undefined, |
|||
withExcludedAccountsAndActivities: true |
|||
}); |
|||
|
|||
const { count } = await this.prismaService.order.deleteMany({ |
|||
where: { |
|||
id: { |
|||
in: activities.map(({ id }) => { |
|||
return id; |
|||
}) |
|||
} |
|||
} |
|||
}); |
|||
|
|||
const symbolProfiles = |
|||
await this.symbolProfileService.getSymbolProfilesByIds( |
|||
activities.map(({ symbolProfileId }) => { |
|||
return symbolProfileId; |
|||
}) |
|||
); |
|||
|
|||
for (const { activitiesCount, id } of symbolProfiles) { |
|||
if (activitiesCount === 0) { |
|||
await this.symbolProfileService.deleteById(id); |
|||
} |
|||
} |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ userId }) |
|||
); |
|||
|
|||
return count; |
|||
} |
|||
|
|||
/** |
|||
* Generates synthetic orders for cash holdings based on account balance history. |
|||
* Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow |
|||
* performance tracking based on exchange rate fluctuations. |
|||
* |
|||
* @param cashDetails - The cash balance details. |
|||
* @param filters - Optional filters to apply. |
|||
* @param userCurrency - The base currency of the user. |
|||
* @param userId - The ID of the user. |
|||
* @returns A response containing the list of synthetic cash activities. |
|||
*/ |
|||
public async getCashOrders({ |
|||
cashDetails, |
|||
filters = [], |
|||
userCurrency, |
|||
userId |
|||
}: { |
|||
cashDetails: CashDetails; |
|||
filters?: Filter[]; |
|||
userCurrency: string; |
|||
userId: string; |
|||
}): Promise<ActivitiesResponse> { |
|||
const filtersByAssetClass = filters.filter(({ type }) => { |
|||
return type === 'ASSET_CLASS'; |
|||
}); |
|||
|
|||
if ( |
|||
filtersByAssetClass.length > 0 && |
|||
!filtersByAssetClass.find(({ id }) => { |
|||
return id === AssetClass.LIQUIDITY; |
|||
}) |
|||
) { |
|||
// If asset class filters are present and none of them is liquidity, return an empty response
|
|||
return { |
|||
activities: [], |
|||
count: 0 |
|||
}; |
|||
} |
|||
|
|||
const activities: Activity[] = []; |
|||
|
|||
for (const account of cashDetails.accounts) { |
|||
const { balances } = await this.accountBalanceService.getAccountBalances({ |
|||
userCurrency, |
|||
userId, |
|||
filters: [{ id: account.id, type: 'ACCOUNT' }] |
|||
}); |
|||
|
|||
let currentBalance = 0; |
|||
let currentBalanceInBaseCurrency = 0; |
|||
|
|||
for (const balanceItem of balances) { |
|||
const syntheticActivityTemplate: Activity = { |
|||
userId, |
|||
accountId: account.id, |
|||
accountUserId: account.userId, |
|||
comment: account.name, |
|||
createdAt: new Date(balanceItem.date), |
|||
currency: account.currency, |
|||
date: new Date(balanceItem.date), |
|||
fee: 0, |
|||
feeInAssetProfileCurrency: 0, |
|||
feeInBaseCurrency: 0, |
|||
id: balanceItem.id, |
|||
isDraft: false, |
|||
quantity: 1, |
|||
SymbolProfile: { |
|||
activitiesCount: 0, |
|||
assetClass: AssetClass.LIQUIDITY, |
|||
assetSubClass: AssetSubClass.CASH, |
|||
countries: [], |
|||
createdAt: new Date(balanceItem.date), |
|||
currency: account.currency, |
|||
dataSource: |
|||
this.dataProviderService.getDataSourceForExchangeRates(), |
|||
holdings: [], |
|||
id: account.currency, |
|||
isActive: true, |
|||
name: account.currency, |
|||
sectors: [], |
|||
symbol: account.currency, |
|||
updatedAt: new Date(balanceItem.date) |
|||
}, |
|||
symbolProfileId: account.currency, |
|||
type: ActivityType.BUY, |
|||
unitPrice: 1, |
|||
unitPriceInAssetProfileCurrency: 1, |
|||
updatedAt: new Date(balanceItem.date), |
|||
valueInBaseCurrency: 0, |
|||
value: 0 |
|||
}; |
|||
|
|||
if (currentBalance < balanceItem.value) { |
|||
// BUY
|
|||
activities.push({ |
|||
...syntheticActivityTemplate, |
|||
quantity: balanceItem.value - currentBalance, |
|||
type: ActivityType.BUY, |
|||
value: balanceItem.value - currentBalance, |
|||
valueInBaseCurrency: |
|||
balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency |
|||
}); |
|||
} else if (currentBalance > balanceItem.value) { |
|||
// SELL
|
|||
activities.push({ |
|||
...syntheticActivityTemplate, |
|||
quantity: currentBalance - balanceItem.value, |
|||
type: ActivityType.SELL, |
|||
value: currentBalance - balanceItem.value, |
|||
valueInBaseCurrency: |
|||
currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency |
|||
}); |
|||
} |
|||
|
|||
currentBalance = balanceItem.value; |
|||
currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency; |
|||
} |
|||
} |
|||
|
|||
return { |
|||
activities, |
|||
count: activities.length |
|||
}; |
|||
} |
|||
|
|||
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { |
|||
return this.prismaService.order.findFirst({ |
|||
orderBy: { |
|||
date: 'desc' |
|||
}, |
|||
where: { |
|||
SymbolProfile: { dataSource, symbol } |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public async getOrders({ |
|||
endDate, |
|||
filters, |
|||
includeDrafts = false, |
|||
skip, |
|||
sortColumn, |
|||
sortDirection = 'asc', |
|||
startDate, |
|||
take = Number.MAX_SAFE_INTEGER, |
|||
types, |
|||
userCurrency, |
|||
userId, |
|||
withExcludedAccountsAndActivities = false |
|||
}: { |
|||
endDate?: Date; |
|||
filters?: Filter[]; |
|||
includeDrafts?: boolean; |
|||
skip?: number; |
|||
sortColumn?: string; |
|||
sortDirection?: Prisma.SortOrder; |
|||
startDate?: Date; |
|||
take?: number; |
|||
types?: ActivityType[]; |
|||
userCurrency: string; |
|||
userId: string; |
|||
withExcludedAccountsAndActivities?: boolean; |
|||
}): Promise<ActivitiesResponse> { |
|||
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [ |
|||
{ date: 'asc' } |
|||
]; |
|||
|
|||
const where: Prisma.OrderWhereInput = { userId }; |
|||
|
|||
if (endDate || startDate) { |
|||
where.AND = []; |
|||
|
|||
if (endDate) { |
|||
where.AND.push({ date: { lte: endDate } }); |
|||
} |
|||
|
|||
if (startDate) { |
|||
where.AND.push({ date: { gt: startDate } }); |
|||
} |
|||
} |
|||
|
|||
const { |
|||
ACCOUNT: filtersByAccount, |
|||
ASSET_CLASS: filtersByAssetClass, |
|||
TAG: filtersByTag |
|||
} = groupBy(filters, ({ type }) => { |
|||
return type; |
|||
}); |
|||
|
|||
const filterByDataSource = filters?.find(({ type }) => { |
|||
return type === 'DATA_SOURCE'; |
|||
})?.id; |
|||
|
|||
const filterBySymbol = filters?.find(({ type }) => { |
|||
return type === 'SYMBOL'; |
|||
})?.id; |
|||
|
|||
const searchQuery = filters?.find(({ type }) => { |
|||
return type === 'SEARCH_QUERY'; |
|||
})?.id; |
|||
|
|||
if (filtersByAccount?.length > 0) { |
|||
where.accountId = { |
|||
in: filtersByAccount.map(({ id }) => { |
|||
return id; |
|||
}) |
|||
}; |
|||
} |
|||
|
|||
if (includeDrafts === false) { |
|||
where.isDraft = false; |
|||
} |
|||
|
|||
if (filtersByAssetClass?.length > 0) { |
|||
where.SymbolProfile = { |
|||
OR: [ |
|||
{ |
|||
AND: [ |
|||
{ |
|||
OR: filtersByAssetClass.map(({ id }) => { |
|||
return { assetClass: AssetClass[id] }; |
|||
}) |
|||
}, |
|||
{ |
|||
OR: [ |
|||
{ SymbolProfileOverrides: { is: null } }, |
|||
{ SymbolProfileOverrides: { assetClass: null } } |
|||
] |
|||
} |
|||
] |
|||
}, |
|||
{ |
|||
SymbolProfileOverrides: { |
|||
OR: filtersByAssetClass.map(({ id }) => { |
|||
return { assetClass: AssetClass[id] }; |
|||
}) |
|||
} |
|||
} |
|||
] |
|||
}; |
|||
} |
|||
|
|||
if (filterByDataSource && filterBySymbol) { |
|||
if (where.SymbolProfile) { |
|||
where.SymbolProfile = { |
|||
AND: [ |
|||
where.SymbolProfile, |
|||
{ |
|||
AND: [ |
|||
{ dataSource: filterByDataSource as DataSource }, |
|||
{ symbol: filterBySymbol } |
|||
] |
|||
} |
|||
] |
|||
}; |
|||
} else { |
|||
where.SymbolProfile = { |
|||
AND: [ |
|||
{ dataSource: filterByDataSource as DataSource }, |
|||
{ symbol: filterBySymbol } |
|||
] |
|||
}; |
|||
} |
|||
} |
|||
|
|||
if (searchQuery) { |
|||
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ |
|||
{ id: { mode: 'insensitive', startsWith: searchQuery } }, |
|||
{ isin: { mode: 'insensitive', startsWith: searchQuery } }, |
|||
{ name: { mode: 'insensitive', startsWith: searchQuery } }, |
|||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } } |
|||
]; |
|||
|
|||
if (where.SymbolProfile) { |
|||
where.SymbolProfile = { |
|||
AND: [ |
|||
where.SymbolProfile, |
|||
{ |
|||
OR: searchQueryWhereInput |
|||
} |
|||
] |
|||
}; |
|||
} else { |
|||
where.SymbolProfile = { |
|||
OR: searchQueryWhereInput |
|||
}; |
|||
} |
|||
} |
|||
|
|||
if (filtersByTag?.length > 0) { |
|||
where.tags = { |
|||
some: { |
|||
OR: filtersByTag.map(({ id }) => { |
|||
return { id }; |
|||
}) |
|||
} |
|||
}; |
|||
} |
|||
|
|||
if (sortColumn) { |
|||
orderBy = [{ [sortColumn]: sortDirection }]; |
|||
} |
|||
|
|||
if (types) { |
|||
where.type = { in: types }; |
|||
} |
|||
|
|||
if (withExcludedAccountsAndActivities === false) { |
|||
where.OR = [ |
|||
{ account: null }, |
|||
{ account: { NOT: { isExcluded: true } } } |
|||
]; |
|||
|
|||
where.tags = { |
|||
...where.tags, |
|||
none: { |
|||
id: TAG_ID_EXCLUDE_FROM_ANALYSIS |
|||
} |
|||
}; |
|||
} |
|||
|
|||
const [orders, count] = await Promise.all([ |
|||
this.orders({ |
|||
skip, |
|||
take, |
|||
where, |
|||
include: { |
|||
account: { |
|||
include: { |
|||
platform: true |
|||
} |
|||
}, |
|||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|||
SymbolProfile: true, |
|||
tags: true |
|||
}, |
|||
orderBy: [...orderBy, { id: sortDirection }] |
|||
}), |
|||
this.prismaService.order.count({ where }) |
|||
]); |
|||
|
|||
const assetProfileIdentifiers = uniqBy( |
|||
orders.map(({ SymbolProfile }) => { |
|||
return { |
|||
dataSource: SymbolProfile.dataSource, |
|||
symbol: SymbolProfile.symbol |
|||
}; |
|||
}), |
|||
({ dataSource, symbol }) => { |
|||
return getAssetProfileIdentifier({ |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
} |
|||
); |
|||
|
|||
const assetProfiles = await this.symbolProfileService.getSymbolProfiles( |
|||
assetProfileIdentifiers |
|||
); |
|||
|
|||
const activities = await Promise.all( |
|||
orders.map(async (order) => { |
|||
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => { |
|||
return ( |
|||
dataSource === order.SymbolProfile.dataSource && |
|||
symbol === order.SymbolProfile.symbol |
|||
); |
|||
}); |
|||
|
|||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); |
|||
|
|||
const [ |
|||
feeInAssetProfileCurrency, |
|||
feeInBaseCurrency, |
|||
unitPriceInAssetProfileCurrency, |
|||
valueInBaseCurrency |
|||
] = await Promise.all([ |
|||
this.exchangeRateDataService.toCurrencyAtDate( |
|||
order.fee, |
|||
order.currency ?? order.SymbolProfile.currency, |
|||
order.SymbolProfile.currency, |
|||
order.date |
|||
), |
|||
this.exchangeRateDataService.toCurrencyAtDate( |
|||
order.fee, |
|||
order.currency ?? order.SymbolProfile.currency, |
|||
userCurrency, |
|||
order.date |
|||
), |
|||
this.exchangeRateDataService.toCurrencyAtDate( |
|||
order.unitPrice, |
|||
order.currency ?? order.SymbolProfile.currency, |
|||
order.SymbolProfile.currency, |
|||
order.date |
|||
), |
|||
this.exchangeRateDataService.toCurrencyAtDate( |
|||
value, |
|||
order.currency ?? order.SymbolProfile.currency, |
|||
userCurrency, |
|||
order.date |
|||
) |
|||
]); |
|||
|
|||
return { |
|||
...order, |
|||
feeInAssetProfileCurrency, |
|||
feeInBaseCurrency, |
|||
unitPriceInAssetProfileCurrency, |
|||
value, |
|||
valueInBaseCurrency, |
|||
SymbolProfile: assetProfile |
|||
}; |
|||
}) |
|||
); |
|||
|
|||
return { activities, count }; |
|||
} |
|||
|
|||
/** |
|||
* Retrieves all orders required for the portfolio calculator, including both standard asset orders |
|||
* and optional synthetic orders representing cash activities. |
|||
*/ |
|||
@LogPerformance |
|||
public async getOrdersForPortfolioCalculator({ |
|||
filters, |
|||
userCurrency, |
|||
userId, |
|||
withCash = false |
|||
}: { |
|||
/** Optional filters to apply to the orders. */ |
|||
filters?: Filter[]; |
|||
/** The base currency of the user. */ |
|||
userCurrency: string; |
|||
/** The ID of the user. */ |
|||
userId: string; |
|||
/** Whether to include cash activities in the result. */ |
|||
withCash?: boolean; |
|||
}) { |
|||
const orders = await this.getOrders({ |
|||
filters, |
|||
userCurrency, |
|||
userId, |
|||
withExcludedAccountsAndActivities: false // TODO
|
|||
}); |
|||
|
|||
if (withCash) { |
|||
const cashDetails = await this.accountService.getCashDetails({ |
|||
filters, |
|||
userId, |
|||
currency: userCurrency |
|||
}); |
|||
|
|||
const cashOrders = await this.getCashOrders({ |
|||
cashDetails, |
|||
filters, |
|||
userCurrency, |
|||
userId |
|||
}); |
|||
|
|||
orders.activities.push(...cashOrders.activities); |
|||
orders.count += cashOrders.count; |
|||
} |
|||
|
|||
return orders; |
|||
} |
|||
|
|||
public async getStatisticsByCurrency( |
|||
currency: EnhancedSymbolProfile['currency'] |
|||
): Promise<{ |
|||
activitiesCount: EnhancedSymbolProfile['activitiesCount']; |
|||
dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; |
|||
}> { |
|||
const { _count, _min } = await this.prismaService.order.aggregate({ |
|||
_count: true, |
|||
_min: { |
|||
date: true |
|||
}, |
|||
where: { SymbolProfile: { currency } } |
|||
}); |
|||
|
|||
return { |
|||
activitiesCount: _count as number, |
|||
dateOfFirstActivity: _min.date |
|||
}; |
|||
} |
|||
|
|||
public async order( |
|||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput |
|||
): Promise<Order | null> { |
|||
return this.prismaService.order.findUnique({ |
|||
where: orderWhereUniqueInput |
|||
}); |
|||
} |
|||
|
|||
public async updateOrder({ |
|||
data, |
|||
where |
|||
}: { |
|||
data: Prisma.OrderUpdateInput & { |
|||
assetClass?: AssetClass; |
|||
assetSubClass?: AssetSubClass; |
|||
currency?: string; |
|||
symbol?: string; |
|||
tags?: { id: string }[]; |
|||
type?: ActivityType; |
|||
}; |
|||
where: Prisma.OrderWhereUniqueInput; |
|||
}): Promise<Order> { |
|||
if (!data.comment) { |
|||
data.comment = null; |
|||
} |
|||
|
|||
const tags = data.tags ?? []; |
|||
|
|||
let isDraft = false; |
|||
|
|||
if ( |
|||
['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || |
|||
(data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && |
|||
data.type === 'BUY') |
|||
) { |
|||
if (data.account?.connect?.id_userId?.id === null) { |
|||
data.account = { disconnect: true }; |
|||
} |
|||
|
|||
delete data.SymbolProfile.connect; |
|||
delete data.SymbolProfile.update.name; |
|||
} else { |
|||
delete data.SymbolProfile.update; |
|||
|
|||
isDraft = isAfter(data.date as Date, endOfToday()); |
|||
|
|||
if (!isDraft) { |
|||
// Gather symbol data of order in the background, if not draft
|
|||
this.dataGatheringService.gatherSymbols({ |
|||
dataGatheringItems: [ |
|||
{ |
|||
dataSource: |
|||
data.SymbolProfile.connect.dataSource_symbol.dataSource, |
|||
date: data.date as Date, |
|||
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol |
|||
} |
|||
], |
|||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH |
|||
}); |
|||
} |
|||
} |
|||
|
|||
delete data.assetClass; |
|||
delete data.assetSubClass; |
|||
delete data.symbol; |
|||
delete data.tags; |
|||
|
|||
// Remove existing tags
|
|||
await this.prismaService.order.update({ |
|||
where, |
|||
data: { tags: { set: [] } } |
|||
}); |
|||
|
|||
const order = await this.prismaService.order.update({ |
|||
where, |
|||
data: { |
|||
...data, |
|||
isDraft, |
|||
tags: { |
|||
connect: tags |
|||
} |
|||
} |
|||
}); |
|||
|
|||
this.eventEmitter.emit( |
|||
PortfolioChangedEvent.getName(), |
|||
new PortfolioChangedEvent({ |
|||
userId: order.userId |
|||
}) |
|||
); |
|||
|
|||
return order; |
|||
} |
|||
|
|||
private async orders(params: { |
|||
include?: Prisma.OrderInclude; |
|||
skip?: number; |
|||
take?: number; |
|||
cursor?: Prisma.OrderWhereUniqueInput; |
|||
where?: Prisma.OrderWhereInput; |
|||
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>; |
|||
}): Promise<OrderWithAccount[]> { |
|||
const { include, skip, take, cursor, where, orderBy } = params; |
|||
|
|||
return this.prismaService.order.findMany({ |
|||
cursor, |
|||
include, |
|||
orderBy, |
|||
skip, |
|||
take, |
|||
where |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
HttpException, |
|||
Param, |
|||
Post, |
|||
Put, |
|||
UseGuards |
|||
} from '@nestjs/common'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { Platform } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { PlatformService } from './platform.service'; |
|||
|
|||
@Controller('platform') |
|||
export class PlatformController { |
|||
public constructor(private readonly platformService: PlatformService) {} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.readPlatformsWithAccountCount) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async getPlatforms() { |
|||
return this.platformService.getPlatformsWithAccountCount(); |
|||
} |
|||
|
|||
@HasPermission(permissions.createPlatform) |
|||
@Post() |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async createPlatform( |
|||
@Body() data: CreatePlatformDto |
|||
): Promise<Platform> { |
|||
return this.platformService.createPlatform(data); |
|||
} |
|||
|
|||
@HasPermission(permissions.updatePlatform) |
|||
@Put(':id') |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async updatePlatform( |
|||
@Param('id') id: string, |
|||
@Body() data: UpdatePlatformDto |
|||
) { |
|||
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') |
|||
@HasPermission(permissions.deletePlatform) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
public async deletePlatform(@Param('id') id: string) { |
|||
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,14 @@ |
|||
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,84 @@ |
|||
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: { accounts: true } |
|||
} |
|||
} |
|||
}); |
|||
|
|||
return platformsWithAccountCount.map(({ _count, id, name, url }) => { |
|||
return { |
|||
id, |
|||
name, |
|||
url, |
|||
accountCount: _count.accounts |
|||
}; |
|||
}); |
|||
} |
|||
|
|||
public async updatePlatform({ |
|||
data, |
|||
where |
|||
}: { |
|||
data: Prisma.PlatformUpdateInput; |
|||
where: Prisma.PlatformWhereUniqueInput; |
|||
}): Promise<Platform> { |
|||
return this.prismaService.platform.update({ |
|||
data, |
|||
where |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
|||
import { |
|||
AssetProfileIdentifier, |
|||
SymbolMetrics |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { PortfolioSnapshot } from '@ghostfolio/common/models'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
export class MwrPortfolioCalculator extends PortfolioCalculator { |
|||
protected calculateOverallPerformance(): PortfolioSnapshot { |
|||
throw new Error('Method not implemented.'); |
|||
} |
|||
|
|||
protected getPerformanceCalculationType() { |
|||
return PerformanceCalculationType.MWR; |
|||
} |
|||
|
|||
protected getSymbolMetrics({}: { |
|||
end: Date; |
|||
exchangeRates: { [dateString: string]: number }; |
|||
marketSymbolMap: { |
|||
[date: string]: { [symbol: string]: Big }; |
|||
}; |
|||
start: Date; |
|||
step?: number; |
|||
} & AssetProfileIdentifier): SymbolMetrics { |
|||
throw new Error('Method not implemented.'); |
|||
} |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
import { ExportResponse } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { readFileSync } from 'node:fs'; |
|||
|
|||
export const activityDummyData = { |
|||
accountId: undefined, |
|||
accountUserId: undefined, |
|||
comment: undefined, |
|||
createdAt: new Date(), |
|||
currency: undefined, |
|||
fee: undefined, |
|||
feeInAssetProfileCurrency: undefined, |
|||
feeInBaseCurrency: undefined, |
|||
id: undefined, |
|||
isDraft: false, |
|||
symbolProfileId: undefined, |
|||
unitPrice: undefined, |
|||
unitPriceInAssetProfileCurrency: undefined, |
|||
updatedAt: new Date(), |
|||
userId: undefined, |
|||
value: undefined, |
|||
valueInBaseCurrency: undefined |
|||
}; |
|||
|
|||
export const symbolProfileDummyData = { |
|||
activitiesCount: undefined, |
|||
assetClass: undefined, |
|||
assetSubClass: undefined, |
|||
countries: [], |
|||
createdAt: undefined, |
|||
holdings: [], |
|||
id: undefined, |
|||
isActive: true, |
|||
sectors: [], |
|||
updatedAt: undefined |
|||
}; |
|||
|
|||
export const userDummyData = { |
|||
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' |
|||
}; |
|||
|
|||
export function loadExportFile(filePath: string): ExportResponse { |
|||
return JSON.parse(readFileSync(filePath, 'utf8')); |
|||
} |
|||
@ -0,0 +1,107 @@ |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { |
|||
Activity, |
|||
Filter, |
|||
HistoricalDataItem |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Injectable } from '@nestjs/common'; |
|||
|
|||
import { MwrPortfolioCalculator } from './mwr/portfolio-calculator'; |
|||
import { PortfolioCalculator } from './portfolio-calculator'; |
|||
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator'; |
|||
import { RoiPortfolioCalculator } from './roi/portfolio-calculator'; |
|||
import { TwrPortfolioCalculator } from './twr/portfolio-calculator'; |
|||
|
|||
@Injectable() |
|||
export class PortfolioCalculatorFactory { |
|||
public constructor( |
|||
private readonly configurationService: ConfigurationService, |
|||
private readonly currentRateService: CurrentRateService, |
|||
private readonly exchangeRateDataService: ExchangeRateDataService, |
|||
private readonly portfolioSnapshotService: PortfolioSnapshotService, |
|||
private readonly redisCacheService: RedisCacheService |
|||
) {} |
|||
|
|||
public createCalculator({ |
|||
accountBalanceItems = [], |
|||
activities, |
|||
calculationType, |
|||
currency, |
|||
filters = [], |
|||
userId |
|||
}: { |
|||
accountBalanceItems?: HistoricalDataItem[]; |
|||
activities: Activity[]; |
|||
calculationType: PerformanceCalculationType; |
|||
currency: string; |
|||
filters?: Filter[]; |
|||
userId: string; |
|||
}): PortfolioCalculator { |
|||
switch (calculationType) { |
|||
case PerformanceCalculationType.MWR: |
|||
return new MwrPortfolioCalculator({ |
|||
accountBalanceItems, |
|||
activities, |
|||
currency, |
|||
filters, |
|||
userId, |
|||
configurationService: this.configurationService, |
|||
currentRateService: this.currentRateService, |
|||
exchangeRateDataService: this.exchangeRateDataService, |
|||
portfolioSnapshotService: this.portfolioSnapshotService, |
|||
redisCacheService: this.redisCacheService |
|||
}); |
|||
|
|||
case PerformanceCalculationType.ROAI: |
|||
return new RoaiPortfolioCalculator({ |
|||
accountBalanceItems, |
|||
activities, |
|||
currency, |
|||
filters, |
|||
userId, |
|||
configurationService: this.configurationService, |
|||
currentRateService: this.currentRateService, |
|||
exchangeRateDataService: this.exchangeRateDataService, |
|||
portfolioSnapshotService: this.portfolioSnapshotService, |
|||
redisCacheService: this.redisCacheService |
|||
}); |
|||
|
|||
case PerformanceCalculationType.ROI: |
|||
return new RoiPortfolioCalculator({ |
|||
accountBalanceItems, |
|||
activities, |
|||
currency, |
|||
filters, |
|||
userId, |
|||
configurationService: this.configurationService, |
|||
currentRateService: this.currentRateService, |
|||
exchangeRateDataService: this.exchangeRateDataService, |
|||
portfolioSnapshotService: this.portfolioSnapshotService, |
|||
redisCacheService: this.redisCacheService |
|||
}); |
|||
|
|||
case PerformanceCalculationType.TWR: |
|||
return new TwrPortfolioCalculator({ |
|||
accountBalanceItems, |
|||
activities, |
|||
currency, |
|||
filters, |
|||
userId, |
|||
configurationService: this.configurationService, |
|||
currentRateService: this.currentRateService, |
|||
exchangeRateDataService: this.exchangeRateDataService, |
|||
portfolioSnapshotService: this.portfolioSnapshotService, |
|||
redisCacheService: this.redisCacheService |
|||
}); |
|||
|
|||
default: |
|||
throw new Error('Invalid calculation type'); |
|||
} |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,217 @@ |
|||
import { |
|||
activityDummyData, |
|||
symbolProfileDummyData, |
|||
userDummyData |
|||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { Activity } from '@ghostfolio/common/interfaces'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Big } from 'big.js'; |
|||
|
|||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
|||
return { |
|||
CurrentRateService: jest.fn().mockImplementation(() => { |
|||
return CurrentRateServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
jest.mock( |
|||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
|||
() => { |
|||
return { |
|||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
|||
return PortfolioSnapshotServiceMock; |
|||
}) |
|||
}; |
|||
} |
|||
); |
|||
|
|||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
|||
return { |
|||
RedisCacheService: jest.fn().mockImplementation(() => { |
|||
return RedisCacheServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
describe('PortfolioCalculator', () => { |
|||
let configurationService: ConfigurationService; |
|||
let currentRateService: CurrentRateService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioSnapshotService: PortfolioSnapshotService; |
|||
let redisCacheService: RedisCacheService; |
|||
|
|||
beforeEach(() => { |
|||
configurationService = new ConfigurationService(); |
|||
|
|||
currentRateService = new CurrentRateService(null, null, null, null); |
|||
|
|||
exchangeRateDataService = new ExchangeRateDataService( |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
portfolioSnapshotService = new PortfolioSnapshotService(null); |
|||
|
|||
redisCacheService = new RedisCacheService(null, null); |
|||
|
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with BALN.SW buy and buy', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
|||
|
|||
const activities: Activity[] = [ |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-22'), |
|||
feeInAssetProfileCurrency: 1.55, |
|||
feeInBaseCurrency: 1.55, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 142.9 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 1.65, |
|||
feeInBaseCurrency: 1.65, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
} |
|||
]; |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: 'CHF', |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'year' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('595.6'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 2, |
|||
averagePrice: new Big('139.75'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2021-11-22', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('3.2'), |
|||
feeInBaseCurrency: new Big('3.2'), |
|||
grossPerformance: new Big('36.6'), |
|||
grossPerformancePercentage: new Big('0.07706261539956593567'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'0.07706261539956593567' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('36.6'), |
|||
investment: new Big('559'), |
|||
investmentWithCurrencyEffect: new Big('559'), |
|||
netPerformance: new Big('33.4'), |
|||
netPerformancePercentage: new Big('0.07032490039195361342'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('0.06986689805847808234') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('33.4') |
|||
}, |
|||
marketPrice: 148.9, |
|||
marketPriceInBaseCurrency: 148.9, |
|||
quantity: new Big('4'), |
|||
symbol: 'BALN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('474.93846153846153846154'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big( |
|||
'474.93846153846153846154' |
|||
), |
|||
valueInBaseCurrency: new Big('595.6') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('3.2'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('559'), |
|||
totalInvestmentWithCurrencyEffect: new Big('559'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
|||
expect.objectContaining({ |
|||
netPerformance: 33.4, |
|||
netPerformanceInPercentage: 0.07032490039195362, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362, |
|||
netPerformanceWithCurrencyEffect: 33.4, |
|||
totalInvestment: 559, |
|||
totalInvestmentValueWithCurrencyEffect: 559 |
|||
}) |
|||
); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2021-11-22', investment: new Big('285.8') }, |
|||
{ date: '2021-11-30', investment: new Big('559') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2021-11-01', investment: 559 }, |
|||
{ date: '2021-12-01', investment: 0 } |
|||
]); |
|||
|
|||
expect(investmentsByYear).toEqual([ |
|||
{ date: '2021-01-01', investment: 559 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,231 @@ |
|||
import { |
|||
activityDummyData, |
|||
symbolProfileDummyData, |
|||
userDummyData |
|||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { Activity } from '@ghostfolio/common/interfaces'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Big } from 'big.js'; |
|||
|
|||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
|||
return { |
|||
CurrentRateService: jest.fn().mockImplementation(() => { |
|||
return CurrentRateServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
jest.mock( |
|||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
|||
() => { |
|||
return { |
|||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
|||
return PortfolioSnapshotServiceMock; |
|||
}) |
|||
}; |
|||
} |
|||
); |
|||
|
|||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
|||
return { |
|||
RedisCacheService: jest.fn().mockImplementation(() => { |
|||
return RedisCacheServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
describe('PortfolioCalculator', () => { |
|||
let configurationService: ConfigurationService; |
|||
let currentRateService: CurrentRateService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioSnapshotService: PortfolioSnapshotService; |
|||
let redisCacheService: RedisCacheService; |
|||
|
|||
beforeEach(() => { |
|||
configurationService = new ConfigurationService(); |
|||
|
|||
currentRateService = new CurrentRateService(null, null, null, null); |
|||
|
|||
exchangeRateDataService = new ExchangeRateDataService( |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
portfolioSnapshotService = new PortfolioSnapshotService(null); |
|||
|
|||
redisCacheService = new RedisCacheService(null, null); |
|||
|
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with BALN.SW buy and sell in two activities', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
|||
|
|||
const activities: Activity[] = [ |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-22'), |
|||
feeInAssetProfileCurrency: 1.55, |
|||
feeInBaseCurrency: 1.55, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 142.9 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 1.65, |
|||
feeInBaseCurrency: 1.65, |
|||
quantity: 1, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'SELL', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 0, |
|||
feeInBaseCurrency: 0, |
|||
quantity: 1, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'SELL', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
} |
|||
]; |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: 'CHF', |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'year' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 3, |
|||
averagePrice: new Big('0'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2021-11-22', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('3.2'), |
|||
feeInBaseCurrency: new Big('3.2'), |
|||
grossPerformance: new Big('-12.6'), |
|||
grossPerformancePercentage: new Big('-0.04408677396780965649'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'-0.04408677396780965649' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('-0.0552834149755073478') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('-15.8') |
|||
}, |
|||
marketPrice: 148.9, |
|||
marketPriceInBaseCurrency: 148.9, |
|||
quantity: new Big('0'), |
|||
symbol: 'BALN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('285.80000000000000396627'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big( |
|||
'285.80000000000000396627' |
|||
), |
|||
valueInBaseCurrency: new Big('0') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('3.2'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('0'), |
|||
totalInvestmentWithCurrencyEffect: new Big('0'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
|||
expect.objectContaining({ |
|||
netPerformance: -15.8, |
|||
netPerformanceInPercentage: -0.05528341497550734703, |
|||
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, |
|||
netPerformanceWithCurrencyEffect: -15.8, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0 |
|||
}) |
|||
); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2021-11-22', investment: new Big('285.8') }, |
|||
{ date: '2021-11-30', investment: new Big('0') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2021-11-01', investment: 0 }, |
|||
{ date: '2021-12-01', investment: 0 } |
|||
]); |
|||
|
|||
expect(investmentsByYear).toEqual([ |
|||
{ date: '2021-01-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,215 @@ |
|||
import { |
|||
activityDummyData, |
|||
symbolProfileDummyData, |
|||
userDummyData |
|||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
|||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
|||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
|||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
import { Activity } from '@ghostfolio/common/interfaces'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
import { Big } from 'big.js'; |
|||
|
|||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
|||
return { |
|||
CurrentRateService: jest.fn().mockImplementation(() => { |
|||
return CurrentRateServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
jest.mock( |
|||
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
|||
() => { |
|||
return { |
|||
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
|||
return PortfolioSnapshotServiceMock; |
|||
}) |
|||
}; |
|||
} |
|||
); |
|||
|
|||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
|||
return { |
|||
RedisCacheService: jest.fn().mockImplementation(() => { |
|||
return RedisCacheServiceMock; |
|||
}) |
|||
}; |
|||
}); |
|||
|
|||
describe('PortfolioCalculator', () => { |
|||
let configurationService: ConfigurationService; |
|||
let currentRateService: CurrentRateService; |
|||
let exchangeRateDataService: ExchangeRateDataService; |
|||
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
|||
let portfolioSnapshotService: PortfolioSnapshotService; |
|||
let redisCacheService: RedisCacheService; |
|||
|
|||
beforeEach(() => { |
|||
configurationService = new ConfigurationService(); |
|||
|
|||
currentRateService = new CurrentRateService(null, null, null, null); |
|||
|
|||
exchangeRateDataService = new ExchangeRateDataService( |
|||
null, |
|||
null, |
|||
null, |
|||
null |
|||
); |
|||
|
|||
portfolioSnapshotService = new PortfolioSnapshotService(null); |
|||
|
|||
redisCacheService = new RedisCacheService(null, null); |
|||
|
|||
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
|||
configurationService, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService |
|||
); |
|||
}); |
|||
|
|||
describe('get current positions', () => { |
|||
it.only('with BALN.SW buy and sell', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); |
|||
|
|||
const activities: Activity[] = [ |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-22'), |
|||
feeInAssetProfileCurrency: 1.55, |
|||
feeInBaseCurrency: 1.55, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'BUY', |
|||
unitPriceInAssetProfileCurrency: 142.9 |
|||
}, |
|||
{ |
|||
...activityDummyData, |
|||
date: new Date('2021-11-30'), |
|||
feeInAssetProfileCurrency: 1.65, |
|||
feeInBaseCurrency: 1.65, |
|||
quantity: 2, |
|||
SymbolProfile: { |
|||
...symbolProfileDummyData, |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
name: 'Bâloise Holding AG', |
|||
symbol: 'BALN.SW' |
|||
}, |
|||
type: 'SELL', |
|||
unitPriceInAssetProfileCurrency: 136.6 |
|||
} |
|||
]; |
|||
|
|||
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
|||
activities, |
|||
calculationType: PerformanceCalculationType.ROAI, |
|||
currency: 'CHF', |
|||
userId: userDummyData.id |
|||
}); |
|||
|
|||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
|||
|
|||
const investments = portfolioCalculator.getInvestments(); |
|||
|
|||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'month' |
|||
}); |
|||
|
|||
const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ |
|||
data: portfolioSnapshot.historicalData, |
|||
groupBy: 'year' |
|||
}); |
|||
|
|||
expect(portfolioSnapshot).toMatchObject({ |
|||
currentValueInBaseCurrency: new Big('0'), |
|||
errors: [], |
|||
hasErrors: false, |
|||
positions: [ |
|||
{ |
|||
activitiesCount: 2, |
|||
averagePrice: new Big('0'), |
|||
currency: 'CHF', |
|||
dataSource: 'YAHOO', |
|||
dateOfFirstActivity: '2021-11-22', |
|||
dividend: new Big('0'), |
|||
dividendInBaseCurrency: new Big('0'), |
|||
fee: new Big('3.2'), |
|||
feeInBaseCurrency: new Big('3.2'), |
|||
grossPerformance: new Big('-12.6'), |
|||
grossPerformancePercentage: new Big('-0.0440867739678096571'), |
|||
grossPerformancePercentageWithCurrencyEffect: new Big( |
|||
'-0.0440867739678096571' |
|||
), |
|||
grossPerformanceWithCurrencyEffect: new Big('-12.6'), |
|||
investment: new Big('0'), |
|||
investmentWithCurrencyEffect: new Big('0'), |
|||
netPerformance: new Big('-15.8'), |
|||
netPerformancePercentage: new Big('-0.0552834149755073478'), |
|||
netPerformancePercentageWithCurrencyEffectMap: { |
|||
max: new Big('-0.0552834149755073478') |
|||
}, |
|||
netPerformanceWithCurrencyEffectMap: { |
|||
max: new Big('-15.8') |
|||
}, |
|||
marketPrice: 148.9, |
|||
marketPriceInBaseCurrency: 148.9, |
|||
quantity: new Big('0'), |
|||
symbol: 'BALN.SW', |
|||
tags: [], |
|||
timeWeightedInvestment: new Big('285.8'), |
|||
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), |
|||
valueInBaseCurrency: new Big('0') |
|||
} |
|||
], |
|||
totalFeesWithCurrencyEffect: new Big('3.2'), |
|||
totalInterestWithCurrencyEffect: new Big('0'), |
|||
totalInvestment: new Big('0'), |
|||
totalInvestmentWithCurrencyEffect: new Big('0'), |
|||
totalLiabilitiesWithCurrencyEffect: new Big('0') |
|||
}); |
|||
|
|||
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( |
|||
expect.objectContaining({ |
|||
netPerformance: -15.8, |
|||
netPerformanceInPercentage: -0.05528341497550734703, |
|||
netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, |
|||
netPerformanceWithCurrencyEffect: -15.8, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0 |
|||
}) |
|||
); |
|||
|
|||
expect(investments).toEqual([ |
|||
{ date: '2021-11-22', investment: new Big('285.8') }, |
|||
{ date: '2021-11-30', investment: new Big('0') } |
|||
]); |
|||
|
|||
expect(investmentsByMonth).toEqual([ |
|||
{ date: '2021-11-01', investment: 0 }, |
|||
{ date: '2021-12-01', investment: 0 } |
|||
]); |
|||
|
|||
expect(investmentsByYear).toEqual([ |
|||
{ date: '2021-01-01', investment: 0 } |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue