From 9ea2405fec102625371becdeea243d71c152f84b Mon Sep 17 00:00:00 2001 From: Sjohn21 Date: Fri, 12 Jun 2026 20:09:43 +0200 Subject: [PATCH] Feature/extend public API with endpoint to update asset profile data (#6981) * Extend public API with endpoint to update asset profile data * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 1 + README.md | 52 +++++++++++ apps/api/src/app/admin/admin.service.ts | 16 ++-- apps/api/src/app/app.module.ts | 2 + .../asset-profiles.controller.ts | 51 +++++++++++ .../asset-profiles/asset-profiles.module.ts | 13 +++ .../asset-profiles/asset-profiles.service.ts | 90 +++++++++++++++++++ .../symbol-profile/symbol-profile.service.ts | 27 +++++- libs/common/src/lib/dtos/index.ts | 2 + .../lib/dtos/update-asset-profile-data.dto.ts | 16 ++++ .../src/lib/dtos/update-asset-profile.dto.ts | 4 + 11 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts create mode 100644 apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts create mode 100644 apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts create mode 100644 libs/common/src/lib/dtos/update-asset-profile-data.dto.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c0c0835c1..d5ffbd4f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Extended the _Public API_ with the endpoint to update the asset profile data (`PATCH api/v1/asset-profiles/:dataSource/:symbol`) (experimental) - Added support for a dedicated _OpenRouter_ model for the `web_fetch` tool in the `FetchService` ### Changed diff --git a/README.md b/README.md index 8557d4330..270b65126 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,58 @@ Grant access of type _Public_ in the _Access_ tab of _My Ghostfolio_. } ``` +### Update Asset Profile Data (experimental) + +#### Prerequisites + +[Bearer Token](#authorization-bearer-token) for authorization with admin role + +#### Request + +`PATCH http://localhost:3333/api/v1/asset-profiles//` + +#### Body + +``` +{ + "countries": [ + { + "code": "US", + "weight": 1 + } + ], + "sectors": [ + { + "name": "Technology", + "weight": 1 + } + ] +} +``` + +| Field | Type | Description | +| ----------- | ------------------ | ---------------------------------------------------------------------- | +| `countries` | `array` (optional) | Countries with `code` (`ISO 3166-1 alpha-2`) and `weight` (`0` to `1`) | +| `holdings` | `array` (optional) | Holdings with `name` and `weight` (`0` to `1`) | +| `sectors` | `array` (optional) | Sectors with `name` and `weight` (`0` to `1`) | + +#### Response + +##### Success + +`200 OK` + +##### Error + +`404 Not Found` + +``` +{ + "error": "Not Found", + "message": "Could not find the asset profile for MSFT (YAHOO)" +} +``` + ## Community Projects Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 948616d6c..7e7202306 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -593,6 +593,7 @@ export class AdminService { assetClass: assetClass as AssetClass, assetSubClass: assetSubClass as AssetSubClass, countries: countries as Prisma.JsonArray, + holdings: holdings as Prisma.JsonArray, name: name as string, sectors: sectors as Prisma.JsonArray, url: url as string @@ -602,21 +603,14 @@ export class AdminService { comment, currency, dataSource, - holdings, isActive, scraperConfiguration, symbol, symbolMapping, - ...(dataSource === 'MANUAL' - ? { assetClass, assetSubClass, countries, name, sectors, url } - : { - SymbolProfileOverrides: { - upsert: { - create: symbolProfileOverrides, - update: symbolProfileOverrides - } - } - }) + ...this.symbolProfileService.getAssetProfileUpdateInput( + { dataSource, symbol }, + symbolProfileOverrides + ) }; await this.symbolProfileService.updateSymbolProfile( diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4857c7e14..0a27faa64 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -38,6 +38,7 @@ 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 { AssetProfilesModule } from './endpoints/asset-profiles/asset-profiles.module'; import { AssetsModule } from './endpoints/assets/assets.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; @@ -69,6 +70,7 @@ import { UserModule } from './user/user.module'; ActivitiesModule, AiModule, ApiKeysModule, + AssetProfilesModule, AssetModule, AssetsModule, AuthDeviceModule, 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 new file mode 100644 index 000000000..38227c555 --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts @@ -0,0 +1,51 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; +import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + HttpException, + Inject, + Param, + Patch, + UseGuards +} 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 { AssetProfilesService } from './asset-profiles.service'; + +@Controller('asset-profiles') +export class AssetProfilesController { + public constructor( + private readonly assetProfilesService: AssetProfilesService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @HasPermission(permissions.accessAdminControl) + @Patch(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateAssetProfileData( + @Body() assetProfileData: UpdateAssetProfileDataDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + if (!this.request.user.settings.settings.isExperimentalFeatures) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return this.assetProfilesService.updateAssetProfileData( + { dataSource, symbol }, + assetProfileData + ); + } +} 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 new file mode 100644 index 000000000..32b9ab393 --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts @@ -0,0 +1,13 @@ +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { AssetProfilesController } from './asset-profiles.controller'; +import { AssetProfilesService } from './asset-profiles.service'; + +@Module({ + controllers: [AssetProfilesController], + imports: [SymbolProfileModule], + providers: [AssetProfilesService] +}) +export class AssetProfilesModule {} 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 new file mode 100644 index 000000000..ef24372af --- /dev/null +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts @@ -0,0 +1,90 @@ +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; +import { + AssetProfileIdentifier, + EnhancedSymbolProfile +} from '@ghostfolio/common/interfaces'; + +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class AssetProfilesService { + public constructor( + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async updateAssetProfileData( + { dataSource, symbol }: AssetProfileIdentifier, + assetProfileData: UpdateAssetProfileDataDto + ): Promise { + const notFoundMessage = `Could not find the asset profile for ${symbol} (${dataSource})`; + + const data = this.getAssetProfileDataUpdate(assetProfileData); + + if (Object.keys(data).length > 0) { + try { + await this.symbolProfileService.updateSymbolProfile( + { + dataSource, + symbol + }, + this.symbolProfileService.getAssetProfileUpdateInput( + { dataSource, symbol }, + data + ) + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + throw new NotFoundException(notFoundMessage); + } + + throw error; + } + } + + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]); + + if (!assetProfile) { + throw new NotFoundException(notFoundMessage); + } + + return assetProfile; + } + + private getAssetProfileDataUpdate({ + countries, + holdings, + sectors + }: UpdateAssetProfileDataDto): Pick< + Prisma.SymbolProfileUpdateInput, + 'countries' | 'holdings' | 'sectors' + > { + const data: Pick< + Prisma.SymbolProfileUpdateInput, + 'countries' | 'holdings' | 'sectors' + > = {}; + + if (countries !== undefined) { + data.countries = countries as Prisma.JsonArray; + } + + if (holdings !== undefined) { + data.holdings = holdings as Prisma.JsonArray; + } + + if (sectors !== undefined) { + data.sectors = sectors as Prisma.JsonArray; + } + + return data; + } +} diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 413b7db03..2d5116274 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -11,7 +11,12 @@ import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Injectable } from '@nestjs/common'; -import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client'; +import { + DataSource, + Prisma, + SymbolProfile, + SymbolProfileOverrides +} from '@prisma/client'; import { continents, countries } from 'countries-list'; @Injectable() @@ -71,6 +76,26 @@ export class SymbolProfileService { }); } + public getAssetProfileUpdateInput( + { dataSource }: AssetProfileIdentifier, + data: Prisma.SymbolProfileUpdateInput + ): Prisma.SymbolProfileUpdateInput { + if (dataSource === DataSource.MANUAL) { + return data; + } + + return { + SymbolProfileOverrides: { + upsert: { + create: + data as Prisma.SymbolProfileOverridesCreateWithoutSymbolProfileInput, + update: + data as Prisma.SymbolProfileOverridesUpdateWithoutSymbolProfileInput + } + } + }; + } + public async getSymbolProfiles( aAssetProfileIdentifiers: AssetProfileIdentifier[] ): Promise { diff --git a/libs/common/src/lib/dtos/index.ts b/libs/common/src/lib/dtos/index.ts index 3631d6eae..cf0ce6f57 100644 --- a/libs/common/src/lib/dtos/index.ts +++ b/libs/common/src/lib/dtos/index.ts @@ -13,6 +13,7 @@ import { DeleteOwnUserDto } from './delete-own-user.dto'; import { TransferBalanceDto } from './transfer-balance.dto'; import { UpdateAccessDto } from './update-access.dto'; import { UpdateAccountDto } from './update-account.dto'; +import { UpdateAssetProfileDataDto } from './update-asset-profile-data.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto'; import { UpdateMarketDataDto } from './update-market-data.dto'; @@ -39,6 +40,7 @@ export { TransferBalanceDto, UpdateAccessDto, UpdateAccountDto, + UpdateAssetProfileDataDto, UpdateAssetProfileDto, UpdateBulkMarketDataDto, UpdateMarketDataDto, diff --git a/libs/common/src/lib/dtos/update-asset-profile-data.dto.ts b/libs/common/src/lib/dtos/update-asset-profile-data.dto.ts new file mode 100644 index 000000000..a2f600fcd --- /dev/null +++ b/libs/common/src/lib/dtos/update-asset-profile-data.dto.ts @@ -0,0 +1,16 @@ +import { Prisma } from '@prisma/client'; +import { IsArray, IsOptional } from 'class-validator'; + +export class UpdateAssetProfileDataDto { + @IsArray() + @IsOptional() + countries?: Prisma.InputJsonArray; + + @IsArray() + @IsOptional() + holdings?: Prisma.InputJsonArray; + + @IsArray() + @IsOptional() + sectors?: Prisma.InputJsonArray; +} diff --git a/libs/common/src/lib/dtos/update-asset-profile.dto.ts b/libs/common/src/lib/dtos/update-asset-profile.dto.ts index a4981493e..1c8af3e72 100644 --- a/libs/common/src/lib/dtos/update-asset-profile.dto.ts +++ b/libs/common/src/lib/dtos/update-asset-profile.dto.ts @@ -36,6 +36,10 @@ export class UpdateAssetProfileDto { @IsOptional() dataSource?: DataSource; + @IsArray() + @IsOptional() + holdings?: Prisma.InputJsonArray; + @IsBoolean() @IsOptional() isActive?: boolean;