Feature/create watchlist API endpoints (#4570)

* Create watchlist API endpoints

* Update changelog
This commit is contained in:
Kenrick Tandrian 2025-04-22 01:19:59 +07:00 committed by GitHub
parent b77afed38c
commit 416fa44cf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 213 additions and 2 deletions

View File

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added the endpoints (`DELETE`, `GET` and `POST`) for the watchlist
## 2.154.0 - 2025-04-21 ## 2.154.0 - 2025-04-21
### Added ### Added

View File

@ -38,6 +38,7 @@ import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfol
import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { TagsModule } from './endpoints/tags/tags.module'; import { TagsModule } from './endpoints/tags/tags.module';
import { WatchlistModule } from './endpoints/watchlist/watchlist.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@ -128,7 +129,8 @@ import { UserModule } from './user/user.module';
SymbolModule, SymbolModule,
TagsModule, TagsModule,
TwitterBotModule, TwitterBotModule,
UserModule UserModule,
WatchlistModule
], ],
providers: [CronService] providers: [CronService]
}) })

View File

@ -0,0 +1,10 @@
import { DataSource } from '@prisma/client';
import { IsEnum, IsString } from 'class-validator';
export class CreateWatchlistItemDto {
@IsEnum(DataSource)
dataSource: DataSource;
@IsString()
symbol: string;
}

View File

@ -0,0 +1,85 @@
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 { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
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 { CreateWatchlistItemDto } from './create-watchlist-item.dto';
import { WatchlistService } from './watchlist.service';
@Controller('watchlist')
export class WatchlistController {
public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService
) {}
@Post()
@HasPermission(permissions.createWatchlistItem)
@UseGuards(AuthGuard('jwt'))
@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 watchlistItem = await this.watchlistService
.getWatchlistItems(this.request.user.id)
.then((items) => {
return items.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(): Promise<AssetProfileIdentifier[]> {
return this.watchlistService.getWatchlistItems(this.request.user.id);
}
}

View File

@ -0,0 +1,19 @@
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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { WatchlistController } from './watchlist.controller';
import { WatchlistService } from './watchlist.service';
@Module({
controllers: [WatchlistController],
imports: [
PrismaModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [WatchlistService]
})
export class WatchlistModule {}

View File

@ -0,0 +1,79 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@Injectable()
export class WatchlistService {
public constructor(private readonly prismaService: PrismaService) {}
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) {
throw new NotFoundException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
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<AssetProfileIdentifier[]> {
const user = await this.prismaService.user.findUnique({
select: {
watchlist: {
select: { dataSource: true, symbol: true }
}
},
where: { id: userId }
});
return user.watchlist ?? [];
}
}

View File

@ -17,6 +17,7 @@ export const permissions = {
createPlatform: 'createPlatform', createPlatform: 'createPlatform',
createTag: 'createTag', createTag: 'createTag',
createUserAccount: 'createUserAccount', createUserAccount: 'createUserAccount',
createWatchlistItem: 'createWatchlistItem',
deleteAccess: 'deleteAccess', deleteAccess: 'deleteAccess',
deleteAccount: 'deleteAccount', deleteAccount: 'deleteAccount',
deleteAccountBalance: 'deleteAccountBalance', deleteAccountBalance: 'deleteAccountBalance',
@ -26,6 +27,7 @@ export const permissions = {
deletePlatform: 'deletePlatform', deletePlatform: 'deletePlatform',
deleteTag: 'deleteTag', deleteTag: 'deleteTag',
deleteUser: 'deleteUser', deleteUser: 'deleteUser',
deleteWatchlistItem: 'deleteWatchlistItem',
enableDataProviderGhostfolio: 'enableDataProviderGhostfolio', enableDataProviderGhostfolio: 'enableDataProviderGhostfolio',
enableFearAndGreedIndex: 'enableFearAndGreedIndex', enableFearAndGreedIndex: 'enableFearAndGreedIndex',
enableImport: 'enableImport', enableImport: 'enableImport',
@ -41,6 +43,7 @@ export const permissions = {
readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile',
readPlatforms: 'readPlatforms', readPlatforms: 'readPlatforms',
readTags: 'readTags', readTags: 'readTags',
readWatchlist: 'readWatchlist',
reportDataGlitch: 'reportDataGlitch', reportDataGlitch: 'reportDataGlitch',
toggleReadOnlyMode: 'toggleReadOnlyMode', toggleReadOnlyMode: 'toggleReadOnlyMode',
updateAccount: 'updateAccount', updateAccount: 'updateAccount',
@ -64,7 +67,9 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccess, permissions.createAccess,
permissions.createAccount, permissions.createAccount,
permissions.createAccountBalance, permissions.createAccountBalance,
permissions.createWatchlistItem,
permissions.deleteAccountBalance, permissions.deleteAccountBalance,
permissions.deleteWatchlistItem,
permissions.createMarketData, permissions.createMarketData,
permissions.createMarketDataOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile,
permissions.createOrder, permissions.createOrder,
@ -84,6 +89,7 @@ export function getPermissions(aRole: Role): string[] {
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.readPlatforms, permissions.readPlatforms,
permissions.readTags, permissions.readTags,
permissions.readWatchlist,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateMarketData, permissions.updateMarketData,
@ -100,7 +106,8 @@ export function getPermissions(aRole: Role): string[] {
permissions.accessAssistant, permissions.accessAssistant,
permissions.accessHoldingsChart, permissions.accessHoldingsChart,
permissions.createUserAccount, permissions.createUserAccount,
permissions.readAiPrompt permissions.readAiPrompt,
permissions.readWatchlist
]; ];
case 'USER': case 'USER':
@ -113,14 +120,17 @@ export function getPermissions(aRole: Role): string[] {
permissions.createMarketDataOfOwnAssetProfile, permissions.createMarketDataOfOwnAssetProfile,
permissions.createOrder, permissions.createOrder,
permissions.createOwnTag, permissions.createOwnTag,
permissions.createWatchlistItem,
permissions.deleteAccess, permissions.deleteAccess,
permissions.deleteAccount, permissions.deleteAccount,
permissions.deleteAccountBalance, permissions.deleteAccountBalance,
permissions.deleteAuthDevice, permissions.deleteAuthDevice,
permissions.deleteOrder, permissions.deleteOrder,
permissions.deleteOwnUser, permissions.deleteOwnUser,
permissions.deleteWatchlistItem,
permissions.readAiPrompt, permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile, permissions.readMarketDataOfOwnAssetProfile,
permissions.readWatchlist,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAuthDevice, permissions.updateAuthDevice,
permissions.updateMarketDataOfOwnAssetProfile, permissions.updateMarketDataOfOwnAssetProfile,