From 13388d23af8ae82479a90ded9ba093d350b409a0 Mon Sep 17 00:00:00 2001 From: KenTandrian Date: Sat, 19 Apr 2025 23:42:40 +0700 Subject: [PATCH] feat(api): add watchlist API endpoints --- apps/api/src/app/app.module.ts | 4 +- .../watchlists/create-watchlist-item.dto.ts | 10 +++ .../watchlists/watchlist.controller.ts | 78 ++++++++++++++++ .../endpoints/watchlists/watchlist.module.ts | 13 +++ .../endpoints/watchlists/watchlist.service.ts | 90 +++++++++++++++++++ 5 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/app/endpoints/watchlists/create-watchlist-item.dto.ts create mode 100644 apps/api/src/app/endpoints/watchlists/watchlist.controller.ts create mode 100644 apps/api/src/app/endpoints/watchlists/watchlist.module.ts create mode 100644 apps/api/src/app/endpoints/watchlists/watchlist.service.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 99080e1e1..73f33128a 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/watchlists/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/watchlists/create-watchlist-item.dto.ts b/apps/api/src/app/endpoints/watchlists/create-watchlist-item.dto.ts new file mode 100644 index 000000000..663965ef1 --- /dev/null +++ b/apps/api/src/app/endpoints/watchlists/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/watchlists/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlists/watchlist.controller.ts new file mode 100644 index 000000000..0e319d22e --- /dev/null +++ b/apps/api/src/app/endpoints/watchlists/watchlist.controller.ts @@ -0,0 +1,78 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +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 +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +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 + ) {} + + @Get() + @HasPermission(permissions.readWatchlistItems) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getWatchlistItems(): Promise { + return this.watchlistService.getWatchlistItems(this.request.user.id); + } + + @Post() + @HasPermission(permissions.createWatchlistItem) + @UseGuards(AuthGuard('jwt')) + 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) + public async deleteWatchlistItem( + @Param('dataSource') dataSource: string, + @Param('symbol') symbol: string + ) { + const watchlistItem = await this.watchlistService + .getWatchlistItems(this.request.user.id) + .then((items) => { + return items.find( + (item) => item.dataSource === dataSource && item.symbol === symbol + ); + }); + + if (!watchlistItem) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return this.watchlistService.deleteWatchlistItem({ + dataSource: watchlistItem.dataSource, + symbol, + userId: this.request.user.id + }); + } +} diff --git a/apps/api/src/app/endpoints/watchlists/watchlist.module.ts b/apps/api/src/app/endpoints/watchlists/watchlist.module.ts new file mode 100644 index 000000000..953d15956 --- /dev/null +++ b/apps/api/src/app/endpoints/watchlists/watchlist.module.ts @@ -0,0 +1,13 @@ +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], + providers: [WatchlistService] +}) +export class WatchlistModule {} diff --git a/apps/api/src/app/endpoints/watchlists/watchlist.service.ts b/apps/api/src/app/endpoints/watchlists/watchlist.service.ts new file mode 100644 index 000000000..408b6fba0 --- /dev/null +++ b/apps/api/src/app/endpoints/watchlists/watchlist.service.ts @@ -0,0 +1,90 @@ +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 getWatchlistItems( + userId: string + ): Promise { + const user = await this.prismaService.user.findUnique({ + where: { + id: userId + }, + select: { + watchlist: { + select: { + dataSource: true, + symbol: true + } + } + } + }); + + if (!user) { + throw new NotFoundException( + `User watchlist with ID ${userId} not found.` + ); + } + + return user.watchlist ?? []; + } + + public async createWatchlistItem({ + userId, + dataSource, + symbol + }: { + userId: string; + dataSource: DataSource; + symbol: string; + }): Promise { + const symbolProfile = await this.prismaService.symbolProfile.findUnique({ + where: { dataSource_symbol: { dataSource, symbol } } + }); + if (!symbolProfile) { + throw new NotFoundException(`Symbol ${symbol} not found.`); + } + await this.prismaService.user.update({ + where: { id: userId }, + data: { + watchlist: { + connect: { + dataSource_symbol: { + dataSource, + symbol + } + } + } + } + }); + } + + public async deleteWatchlistItem({ + userId, + dataSource, + symbol + }: { + userId: string; + dataSource: DataSource; + symbol: string; + }) { + await this.prismaService.user.update({ + where: { id: userId }, + data: { + watchlist: { + disconnect: { + dataSource_symbol: { + dataSource, + symbol + } + } + } + } + }); + } +}