diff --git a/apps/api/src/app/account-balance/account-balance.controller.ts b/apps/api/src/app/account-balance/account-balance.controller.ts index 4a8412003..bc454c5ab 100644 --- a/apps/api/src/app/account-balance/account-balance.controller.ts +++ b/apps/api/src/app/account-balance/account-balance.controller.ts @@ -1,7 +1,6 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { resetHours } from '@ghostfolio/common/helper'; import { permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -18,7 +17,6 @@ import { import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { AccountBalance } from '@prisma/client'; -import { parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AccountBalanceService } from './account-balance.service'; @@ -67,10 +65,11 @@ export class AccountBalanceController { @Param('id') id: string ): Promise { const accountBalance = await this.accountBalanceService.accountBalance({ - id + id, + userId: this.request.user.id }); - if (!accountBalance || accountBalance.userId !== this.request.user.id) { + if (!accountBalance) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -78,7 +77,8 @@ export class AccountBalanceController { } return this.accountBalanceService.deleteAccountBalance({ - id + id: accountBalance.id, + userId: accountBalance.userId }); } } diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts index 5a5ec5be0..65393cec8 100644 --- a/apps/api/src/app/account-balance/account-balance.service.ts +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -1,3 +1,4 @@ +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { resetHours } from '@ghostfolio/common/helper'; @@ -5,6 +6,7 @@ import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AccountBalance, Prisma } from '@prisma/client'; import { parseISO } from 'date-fns'; @@ -13,6 +15,7 @@ import { CreateAccountBalanceDto } from './create-account-balance.dto'; @Injectable() export class AccountBalanceService { public constructor( + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService ) {} @@ -36,7 +39,7 @@ export class AccountBalanceService { }: CreateAccountBalanceDto & { userId: string; }): Promise { - return this.prismaService.accountBalance.upsert({ + const accountBalance = await this.prismaService.accountBalance.upsert({ create: { Account: { connect: { @@ -59,14 +62,32 @@ export class AccountBalanceService { } } }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId + }) + ); + + return accountBalance; } public async deleteAccountBalance( where: Prisma.AccountBalanceWhereUniqueInput ): Promise { - return this.prismaService.accountBalance.delete({ + const accountBalance = await this.prismaService.accountBalance.delete({ where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: where.userId + }) + ); + + return accountBalance; } public async getAccountBalances({ diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 6be7e8ffb..1564fa5b3 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -1,10 +1,12 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { Filter } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Account, Order, Platform, Prisma } from '@prisma/client'; import { Big } from 'big.js'; import { format } from 'date-fns'; @@ -16,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface'; export class AccountService { public constructor( private readonly accountBalanceService: AccountBalanceService, + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService ) {} @@ -94,6 +97,13 @@ export class AccountService { userId: aUserId }); + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + return account; } @@ -101,9 +111,18 @@ export class AccountService { where: Prisma.AccountWhereUniqueInput, aUserId: string ): Promise { - return this.prismaService.account.delete({ + const account = await this.prismaService.account.delete({ where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + + return account; } public async getAccounts(aUserId: string): Promise { @@ -201,10 +220,19 @@ export class AccountService { userId: aUserId }); - return this.prismaService.account.update({ + const account = await this.prismaService.account.update({ data, where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + + return account; } public async updateAccountBalance({ diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 5f2be5c8e..67bb9e03c 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,3 +1,4 @@ +import { EventsModule } from '@ghostfolio/api/events/events.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { CronService } from '@ghostfolio/api/services/cron.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; @@ -14,6 +15,7 @@ import { import { BullModule } from '@nestjs/bull'; import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; import { StatusCodes } from 'http-status-codes'; @@ -44,6 +46,7 @@ import { TagModule } from './tag/tag.module'; import { UserModule } from './user/user.module'; @Module({ + controllers: [AppController], imports: [ AdminModule, AccessModule, @@ -64,6 +67,8 @@ import { UserModule } from './user/user.module'; ConfigurationModule, DataGatheringModule, DataProviderModule, + EventEmitterModule.forRoot(), + EventsModule, ExchangeRateModule, ExchangeRateDataModule, ExportModule, @@ -109,7 +114,6 @@ import { UserModule } from './user/user.module'; TwitterBotModule, UserModule ], - controllers: [AppController], providers: [CronService] }) export class AppModule {} diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index 3dadedcaf..4a85dce57 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -60,15 +60,15 @@ export class OrderController { } @Delete(':id') + @HasPermission(permissions.deleteOrder) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async deleteOrder(@Param('id') id: string): Promise { - const order = await this.orderService.order({ id }); + const order = await this.orderService.order({ + id, + userId: this.request.user.id + }); - if ( - !hasPermission(this.request.user.permissions, permissions.deleteOrder) || - !order || - order.userId !== this.request.user.id - ) { + if (!order) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts index 35bfa1bcf..a78daa143 100644 --- a/apps/api/src/app/order/order.service.ts +++ b/apps/api/src/app/order/order.service.ts @@ -1,4 +1,5 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -13,6 +14,7 @@ import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces'; import { OrderWithAccount } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { AssetClass, AssetSubClass, @@ -27,7 +29,6 @@ import { endOfToday, isAfter } from 'date-fns'; import { groupBy, uniqBy } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import { CreateOrderDto } from './create-order.dto'; import { Activities } from './interfaces/activities.interface'; @Injectable() @@ -35,6 +36,7 @@ export class OrderService { public constructor( private readonly accountService: AccountService, private readonly dataGatheringService: DataGatheringService, + private readonly eventEmitter: EventEmitter2, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly prismaService: PrismaService, private readonly symbolProfileService: SymbolProfileService @@ -160,6 +162,13 @@ export class OrderService { }); } + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + return order; } @@ -174,6 +183,13 @@ export class OrderService { await this.symbolProfileService.deleteById(order.symbolProfileId); } + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + return order; } @@ -182,6 +198,13 @@ export class OrderService { where }); + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: where.userId + }) + ); + return count; } @@ -455,7 +478,7 @@ export class OrderService { where }); - return this.prismaService.order.update({ + const order = await this.prismaService.order.update({ data: { ...data, isDraft, @@ -467,6 +490,15 @@ export class OrderService { }, where }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + + return order; } private async orders(params: { diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4cc60770f..3a370d88a 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -1,5 +1,6 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { environment } from '@ghostfolio/api/environments/environment'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -25,6 +26,7 @@ import { import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, Role, User } from '@prisma/client'; import { differenceInDays } from 'date-fns'; import { sortBy, without } from 'lodash'; @@ -37,6 +39,7 @@ export class UserService { public constructor( private readonly configurationService: ConfigurationService, + private readonly eventEmitter: EventEmitter2, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, private readonly subscriptionService: SubscriptionService, @@ -437,11 +440,9 @@ export class UserService { userId: string; userSettings: UserSettings; }) { - const settings = userSettings as unknown as Prisma.JsonObject; - - await this.prismaService.settings.upsert({ + const { settings } = await this.prismaService.settings.upsert({ create: { - settings, + settings: userSettings as unknown as Prisma.JsonObject, User: { connect: { id: userId @@ -449,14 +450,21 @@ export class UserService { } }, update: { - settings + settings: userSettings as unknown as Prisma.JsonObject }, where: { userId } }); - return; + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId + }) + ); + + return settings; } private getRandomString(length: number) { diff --git a/apps/api/src/events/events.module.ts b/apps/api/src/events/events.module.ts new file mode 100644 index 000000000..bf9708f4b --- /dev/null +++ b/apps/api/src/events/events.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +import { PortfolioChangedListener } from './portfolio-changed.listener'; + +@Module({ + providers: [PortfolioChangedListener] +}) +export class EventsModule {} diff --git a/apps/api/src/events/portfolio-changed.event.ts b/apps/api/src/events/portfolio-changed.event.ts new file mode 100644 index 000000000..a3b0710fb --- /dev/null +++ b/apps/api/src/events/portfolio-changed.event.ts @@ -0,0 +1,15 @@ +export class PortfolioChangedEvent { + private userId: string; + + public constructor({ userId }: { userId: string }) { + this.userId = userId; + } + + public static getName() { + return 'portfolio.changed'; + } + + public getUserId() { + return this.userId; + } +} diff --git a/apps/api/src/events/portfolio-changed.listener.ts b/apps/api/src/events/portfolio-changed.listener.ts new file mode 100644 index 000000000..3dd856084 --- /dev/null +++ b/apps/api/src/events/portfolio-changed.listener.ts @@ -0,0 +1,15 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { PortfolioChangedEvent } from './portfolio-changed.event'; + +@Injectable() +export class PortfolioChangedListener { + @OnEvent(PortfolioChangedEvent.getName()) + handlePortfolioChangedEvent(event: PortfolioChangedEvent) { + Logger.log( + `Portfolio of user with id ${event.getUserId()} has changed`, + 'PortfolioChangedListener' + ); + } +} diff --git a/package.json b/package.json index 8b036f588..690918edf 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "@nestjs/common": "10.1.3", "@nestjs/config": "3.0.0", "@nestjs/core": "10.1.3", + "@nestjs/event-emitter": "2.0.4", "@nestjs/jwt": "10.1.0", "@nestjs/passport": "10.0.0", "@nestjs/platform-express": "10.1.3", diff --git a/yarn.lock b/yarn.lock index 5dd3bcd98..f5fd0d164 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4814,6 +4814,13 @@ path-to-regexp "3.2.0" tslib "2.6.1" +"@nestjs/event-emitter@2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@nestjs/event-emitter/-/event-emitter-2.0.4.tgz#de7d3986cfeb82639bb95181fab4fe5525437c74" + integrity sha512-quMiw8yOwoSul0pp3mOonGz8EyXWHSBTqBy8B0TbYYgpnG1Ix2wGUnuTksLWaaBiiOTDhciaZ41Y5fJZsSJE1Q== + dependencies: + eventemitter2 "6.4.9" + "@nestjs/jwt@10.1.0": version "10.1.0" resolved "https://registry.yarnpkg.com/@nestjs/jwt/-/jwt-10.1.0.tgz#7899b68d68b998cc140bc0af392c63fc00755236" @@ -11653,7 +11660,7 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== -eventemitter2@^6.4.2: +eventemitter2@6.4.9, eventemitter2@^6.4.2: version "6.4.9" resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.9.tgz#41f2750781b4230ed58827bc119d293471ecb125" integrity sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==