diff --git a/apps/api/src/app/asset/asset.controller.ts b/apps/api/src/app/asset/asset.controller.ts index de04b5c813..49afb43d71 100644 --- a/apps/api/src/app/asset/asset.controller.ts +++ b/apps/api/src/app/asset/asset.controller.ts @@ -20,7 +20,7 @@ export class AssetController { @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { - const { assetProfile, marketData } = + const { assetProfile, marketData, splits } = await this.assetProfilesService.getAssetProfile({ dataSource, symbol @@ -28,6 +28,7 @@ export class AssetController { return { marketData, + splits, assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) }; } diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts index 6be23c2695..2024902e7f 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts @@ -4,7 +4,10 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; +import { + CreateAssetProfileSplitDto, + UpdateAssetProfileDataDto +} from '@ghostfolio/common/dtos'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { AssetProfileResponse } from '@ghostfolio/common/interfaces'; import { @@ -18,19 +21,22 @@ import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; import { Body, Controller, + Delete, Get, HttpException, Inject, Param, ParseIntPipe, Patch, + Post, Query, UseGuards, UseInterceptors } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { DataSource, Prisma } from '@prisma/client'; +import { AssetProfileSplit, DataSource, Prisma } from '@prisma/client'; +import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AssetProfilesService } from './asset-profiles.service'; @@ -119,6 +125,40 @@ export class AssetProfilesController { }); } + @Post(':dataSource/:symbol/splits') + @UseGuards(AuthGuard('jwt')) + public async createSplit( + @Body() data: CreateAssetProfileSplitDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const { id: symbolProfileId } = await this.validateCanManageSplits({ + dataSource, + symbol + }); + + return this.assetProfilesService.createSplit({ + symbolProfileId, + date: parseISO(data.date), + factor: data.factor + }); + } + + @Delete(':dataSource/:symbol/splits/:id') + @UseGuards(AuthGuard('jwt')) + public async deleteSplit( + @Param('dataSource') dataSource: DataSource, + @Param('id') id: string, + @Param('symbol') symbol: string + ): Promise { + const { id: symbolProfileId } = await this.validateCanManageSplits({ + dataSource, + symbol + }); + + return this.assetProfilesService.deleteSplit({ id, symbolProfileId }); + } + @HasPermission(permissions.accessAdminControl) @Patch(':dataSource/:symbol') @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -139,4 +179,53 @@ export class AssetProfilesController { assetProfileData ); } + + private async validateCanManageSplits({ + dataSource, + symbol + }: { + dataSource: DataSource; + symbol: string; + }) { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const canManageAllAssetProfiles = + hasPermission( + this.request.user.permissions, + permissions.createMarketData + ) && + hasPermission( + this.request.user.permissions, + permissions.updateMarketData + ); + + const canManageOwnAssetProfile = + assetProfile.userId === this.request.user.id && + hasPermission( + this.request.user.permissions, + permissions.createMarketDataOfOwnAssetProfile + ) && + hasPermission( + this.request.user.permissions, + permissions.updateMarketDataOfOwnAssetProfile + ); + + if (!canManageAllAssetProfiles && !canManageOwnAssetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return assetProfile; + } } diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts index f96d92eb3e..e3906be9ff 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts @@ -2,6 +2,7 @@ import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.modu 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 { AssetProfileSplitModule } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.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'; @@ -20,6 +21,7 @@ import { AssetProfilesService } from './asset-profiles.service'; imports: [ ActivitiesModule, ApiModule, + AssetProfileSplitModule, BenchmarkModule, DataProviderModule, ExchangeRateDataModule, diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts index bc6aeff1e6..9772e6cfe3 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts @@ -1,4 +1,5 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.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'; @@ -30,6 +31,7 @@ import { groupBy } from 'lodash'; export class AssetProfilesService { public constructor( private readonly activitiesService: ActivitiesService, + private readonly assetProfileSplitService: AssetProfileSplitService, private readonly benchmarkService: BenchmarkService, private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, @@ -38,6 +40,39 @@ export class AssetProfilesService { private readonly symbolProfileService: SymbolProfileService ) {} + public async createSplit({ + date, + factor, + symbolProfileId + }: { + date: Date; + factor: number; + symbolProfileId: string; + }) { + return this.assetProfileSplitService.upsert({ + date, + factor, + symbolProfileId + }); + } + + public async deleteSplit({ + id, + symbolProfileId + }: { + id: string; + symbolProfileId: string; + }) { + const isDeleted = await this.assetProfileSplitService.deleteById({ + id, + symbolProfileId + }); + + if (!isDeleted) { + throw new NotFoundException(); + } + } + public async getAssetProfile({ dataSource, symbol @@ -54,7 +89,7 @@ export class AssetProfilesService { await this.activitiesService.getStatisticsByCurrency(currency)); } - const [[assetProfile], marketData] = await Promise.all([ + const [[assetProfile], marketData, splits] = await Promise.all([ this.symbolProfileService.getSymbolProfiles([ { dataSource, @@ -69,6 +104,9 @@ export class AssetProfilesService { dataSource, symbol } + }), + this.assetProfileSplitService.getSplits({ + assetProfileIdentifiers: [{ dataSource, symbol }] }) ]); @@ -80,6 +118,7 @@ export class AssetProfilesService { return { marketData, + splits, assetProfile: assetProfile ?? { activitiesCount, currency, diff --git a/libs/common/src/lib/dtos/create-asset-profile-split.dto.ts b/libs/common/src/lib/dtos/create-asset-profile-split.dto.ts new file mode 100644 index 0000000000..0f4f1367b0 --- /dev/null +++ b/libs/common/src/lib/dtos/create-asset-profile-split.dto.ts @@ -0,0 +1,12 @@ +import { IsISO8601, IsNumber, Validate } from 'class-validator'; + +import { IsSplitFactorConstraint } from '../validator-constraints/is-split-factor'; + +export class CreateAssetProfileSplitDto { + @IsISO8601() + date: string; + + @IsNumber() + @Validate(IsSplitFactorConstraint) + factor: number; +} diff --git a/libs/common/src/lib/dtos/index.ts b/libs/common/src/lib/dtos/index.ts index 7851153565..7cf385cd7b 100644 --- a/libs/common/src/lib/dtos/index.ts +++ b/libs/common/src/lib/dtos/index.ts @@ -4,6 +4,7 @@ import { CreateAccessDto } from './create-access.dto'; import { CreateAccountBalanceDto } from './create-account-balance.dto'; import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto'; import { CreateAccountDto } from './create-account.dto'; +import { CreateAssetProfileSplitDto } from './create-asset-profile-split.dto'; import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto'; import { CreateAssetProfileDto } from './create-asset-profile.dto'; import { CreateOrderDto } from './create-order.dto'; @@ -36,6 +37,7 @@ export { CreateAccountDto, CreateAccountWithBalancesDto, CreateAssetProfileDto, + CreateAssetProfileSplitDto, CreateAssetProfileWithMarketDataDto, CreateOrderDto, CreatePlatformDto, diff --git a/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts b/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts index dc44d03823..8cfe70f9c5 100644 --- a/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts +++ b/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts @@ -1,8 +1,9 @@ -import { MarketData } from '@prisma/client'; +import { AssetProfileSplit, MarketData } from '@prisma/client'; import { EnhancedAssetProfile } from './enhanced-asset-profile.interface'; export interface AdminMarketDataDetails { assetProfile: Partial; marketData: MarketData[]; + splits: AssetProfileSplit[]; } diff --git a/libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts b/libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts index d76188c730..26082c2c42 100644 --- a/libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts @@ -1,8 +1,9 @@ -import { MarketData } from '@prisma/client'; +import { AssetProfileSplit, MarketData } from '@prisma/client'; import { EnhancedAssetProfile } from '../enhanced-asset-profile.interface'; export interface AssetProfileResponse { assetProfile: Partial; marketData: MarketData[]; + splits: AssetProfileSplit[]; } diff --git a/libs/common/src/lib/validator-constraints/is-split-factor.ts b/libs/common/src/lib/validator-constraints/is-split-factor.ts new file mode 100644 index 0000000000..40e13eb55e --- /dev/null +++ b/libs/common/src/lib/validator-constraints/is-split-factor.ts @@ -0,0 +1,15 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isSplitFactor' }) +export class IsSplitFactorConstraint implements ValidatorConstraintInterface { + public defaultMessage() { + return 'factor must be a positive number other than 1'; + } + + public validate(aFactor: number) { + return typeof aFactor === 'number' && aFactor > 0 && aFactor !== 1; + } +}