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