diff --git a/CHANGELOG.md b/CHANGELOG.md index d67a81ef9..221a6881f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 99080e1e1..0aca4e62c 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -38,6 +38,7 @@ import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfol import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { PublicModule } from './endpoints/public/public.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'; @@ -128,7 +129,8 @@ import { UserModule } from './user/user.module'; SymbolModule, TagsModule, TwitterBotModule, - UserModule + UserModule, + WatchlistModule ], providers: [CronService] }) diff --git a/apps/api/src/app/endpoints/watchlist/create-watchlist-item.dto.ts b/apps/api/src/app/endpoints/watchlist/create-watchlist-item.dto.ts new file mode 100644 index 000000000..663965ef1 --- /dev/null +++ b/apps/api/src/app/endpoints/watchlist/create-watchlist-item.dto.ts @@ -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; +} diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts new file mode 100644 index 000000000..0d25172c8 --- /dev/null +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -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 { + return this.watchlistService.getWatchlistItems(this.request.user.id); + } +} diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts new file mode 100644 index 000000000..15115888b --- /dev/null +++ b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts @@ -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 {} diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts new file mode 100644 index 000000000..fdb9dd97a --- /dev/null +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -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 { + 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 { + const user = await this.prismaService.user.findUnique({ + select: { + watchlist: { + select: { dataSource: true, symbol: true } + } + }, + where: { id: userId } + }); + + return user.watchlist ?? []; + } +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index f675a278b..592167562 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -17,6 +17,7 @@ export const permissions = { createPlatform: 'createPlatform', createTag: 'createTag', createUserAccount: 'createUserAccount', + createWatchlistItem: 'createWatchlistItem', deleteAccess: 'deleteAccess', deleteAccount: 'deleteAccount', deleteAccountBalance: 'deleteAccountBalance', @@ -26,6 +27,7 @@ export const permissions = { deletePlatform: 'deletePlatform', deleteTag: 'deleteTag', deleteUser: 'deleteUser', + deleteWatchlistItem: 'deleteWatchlistItem', enableDataProviderGhostfolio: 'enableDataProviderGhostfolio', enableFearAndGreedIndex: 'enableFearAndGreedIndex', enableImport: 'enableImport', @@ -41,6 +43,7 @@ export const permissions = { readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', readPlatforms: 'readPlatforms', readTags: 'readTags', + readWatchlist: 'readWatchlist', reportDataGlitch: 'reportDataGlitch', toggleReadOnlyMode: 'toggleReadOnlyMode', updateAccount: 'updateAccount', @@ -64,7 +67,9 @@ export function getPermissions(aRole: Role): string[] { permissions.createAccess, permissions.createAccount, permissions.createAccountBalance, + permissions.createWatchlistItem, permissions.deleteAccountBalance, + permissions.deleteWatchlistItem, permissions.createMarketData, permissions.createMarketDataOfOwnAssetProfile, permissions.createOrder, @@ -84,6 +89,7 @@ export function getPermissions(aRole: Role): string[] { permissions.readMarketDataOfOwnAssetProfile, permissions.readPlatforms, permissions.readTags, + permissions.readWatchlist, permissions.updateAccount, permissions.updateAuthDevice, permissions.updateMarketData, @@ -100,7 +106,8 @@ export function getPermissions(aRole: Role): string[] { permissions.accessAssistant, permissions.accessHoldingsChart, permissions.createUserAccount, - permissions.readAiPrompt + permissions.readAiPrompt, + permissions.readWatchlist ]; case 'USER': @@ -113,14 +120,17 @@ export function getPermissions(aRole: Role): string[] { permissions.createMarketDataOfOwnAssetProfile, permissions.createOrder, permissions.createOwnTag, + permissions.createWatchlistItem, permissions.deleteAccess, permissions.deleteAccount, permissions.deleteAccountBalance, permissions.deleteAuthDevice, permissions.deleteOrder, permissions.deleteOwnUser, + permissions.deleteWatchlistItem, permissions.readAiPrompt, permissions.readMarketDataOfOwnAssetProfile, + permissions.readWatchlist, permissions.updateAccount, permissions.updateAuthDevice, permissions.updateMarketDataOfOwnAssetProfile,