diff --git a/packages/backend/package.json b/packages/backend/package.json index dcc3e21e..e98a13c9 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -47,6 +47,7 @@ "socket.io": "^4.8.1", "typeorm": "^0.3.20", "unzipper": "^0.12.3", + "web-push": "^3.6.7", "winston": "^3.17.0", "winston-daily-rotate-file": "^5.0.0", "ws": "^8.18.0" @@ -63,6 +64,7 @@ "@types/passport-local": "^1.0.38", "@types/supertest": "^6.0.0", "@types/unzipper": "^0.10.10", + "@types/web-push": "^3.6.4", "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", diff --git a/packages/backend/src/alarm/alarm.controller.ts b/packages/backend/src/alarm/alarm.controller.ts new file mode 100644 index 00000000..15dfc480 --- /dev/null +++ b/packages/backend/src/alarm/alarm.controller.ts @@ -0,0 +1,145 @@ +import { + Controller, + Get, + Post, + Param, + Body, + Put, + Delete, + UseGuards, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiParam } from '@nestjs/swagger'; +import { AlarmService } from './alarm.service'; +import { AlarmRequest } from './dto/alarm.request'; +import { AlarmResponse, AlarmSuccessResponse } from './dto/alarm.response'; +import SessionGuard from '@/auth/session/session.guard'; +import { GetUser } from '@/common/decorator/user.decorator'; +import { User } from '@/user/domain/user.entity'; + +@Controller('alarm') +export class AlarmController { + constructor(private readonly alarmService: AlarmService) {} + + @Post() + @ApiOperation({ + summary: '알림 생성', + description: '각 정보에 맞는 알림을 생성한다.', + }) + @ApiOkResponse({ + description: '알림 생성 완료', + type: AlarmResponse, + }) + @UseGuards(SessionGuard) + async create( + @Body() alarmRequest: AlarmRequest, + @GetUser() user: User, + ): Promise { + const userId = user.id; + + return await this.alarmService.create(alarmRequest, userId); + } + + @Get(':id') + @ApiOperation({ + summary: '등록된 알림 확인', + description: '등록된 알림을 알림 아이디를 기준으로 찾을 수 있다.', + }) + @ApiOkResponse({ + description: '알림 아이디와 동일한 알림 찾음', + type: AlarmResponse, + }) + @ApiParam({ + name: 'id', + type: Number, + description: '알림 아이디', + example: 1, + }) + @UseGuards(SessionGuard) + async findOne(@Param('id') alarmId: number): Promise { + return this.alarmService.findOne(alarmId); + } + + @Put(':id') + @ApiOperation({ + summary: '등록된 알림 업데이트', + description: '알림 아이디 기준으로 업데이트를 할 수 있다.', + }) + @ApiOkResponse({ + description: '아이디와 동일한 알림 업데이트', + type: AlarmResponse, + }) + @ApiParam({ + name: 'id', + type: Number, + description: '알림 아이디', + example: 1, + }) + @UseGuards(SessionGuard) + async update( + @Param('id') alarmId: number, + @Body() updateData: AlarmRequest, + ): Promise { + return this.alarmService.update(alarmId, updateData); + } + + @Delete(':id') + @ApiParam({ + name: 'id', + type: Number, + description: '알림 아이디', + example: 1, + }) + @ApiOperation({ + summary: '등록된 알림 삭제', + description: '알림 아이디 기준으로 삭제를 할 수 있다.', + }) + @ApiOkResponse({ + description: '아이디와 동일한 알림 삭제', + type: AlarmSuccessResponse, + }) + @UseGuards(SessionGuard) + async delete(@Param('id') alarmId: number) { + await this.alarmService.delete(alarmId); + + return new AlarmSuccessResponse('알림 삭제를 성공했습니다.'); + } + + @Get('user') + @ApiOperation({ + summary: '사용자별 알림 조회', + description: '사용자 아이디를 기준으로 모든 알림을 조회한다.', + }) + @ApiOkResponse({ + description: '사용자에게 등록되어 있는 모든 알림 조회', + type: [AlarmResponse], + }) + @UseGuards(SessionGuard) + async getByUserId(@GetUser() user: User) { + const userId = user.id; + + return await this.alarmService.findByUserId(userId); + } + + @Get('stock/:stockId') + @ApiOperation({ + summary: '주식별 알림 조회', + description: '주식 아이디를 기준으로 알림을 조회한다.', + }) + @ApiOkResponse({ + description: + '주식 아이디에 등록되어 있는 알림 중 유저에 해당하는 알림 조회', + type: [AlarmResponse], + }) + @ApiParam({ + name: 'id', + type: String, + description: '주식 아이디', + example: '005930', + }) + @UseGuards(SessionGuard) + async getByStockId(@Param('stockId') stockId: string, @GetUser() user: User) { + const userId = user.id; + + return await this.alarmService.findByStockId(stockId, userId); + } +} diff --git a/packages/backend/src/alarm/alarm.module.ts b/packages/backend/src/alarm/alarm.module.ts new file mode 100644 index 00000000..c283febc --- /dev/null +++ b/packages/backend/src/alarm/alarm.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AlarmController } from './alarm.controller'; +import { AlarmService } from './alarm.service'; +import { AlarmSubscriber } from './alarm.subscriber'; +import { Alarm } from './domain/alarm.entity'; +import { PushSubscription } from './domain/subscription.entity'; +import { PushController } from './push.controller'; +import { PushService } from './push.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Alarm, PushSubscription])], + controllers: [AlarmController, PushController], + providers: [AlarmService, PushService, AlarmSubscriber], + exports: [AlarmService], +}) +export class AlarmModule {} diff --git a/packages/backend/src/alarm/alarm.service.ts b/packages/backend/src/alarm/alarm.service.ts new file mode 100644 index 00000000..a2b214ce --- /dev/null +++ b/packages/backend/src/alarm/alarm.service.ts @@ -0,0 +1,115 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { Alarm } from './domain/alarm.entity'; +import { PushSubscription } from './domain/subscription.entity'; +import { AlarmRequest } from './dto/alarm.request'; +import { AlarmResponse } from './dto/alarm.response'; +import { PushService } from './push.service'; +import { User } from '@/user/domain/user.entity'; + +@Injectable() +export class AlarmService { + constructor( + @InjectRepository(Alarm) + private readonly alarmRepository: Repository, + private readonly dataSource: DataSource, + private readonly pushService: PushService, + ) {} + + async create(alarmData: AlarmRequest, userId: number) { + return await this.dataSource.transaction(async (manager) => { + const repository = manager.getRepository(Alarm); + const user = await manager.findOne(User, { where: { id: userId } }); + if (!user) { + throw new ForbiddenException('유저를 찾을 수 없습니다.'); + } + + const newAlarm = repository.create({ + ...alarmData, + user, + stock: { id: alarmData.stockId }, + }); + const result = await repository.save(newAlarm); + return new AlarmResponse(result); + }); + } + + async findByUserId(userId: number) { + const result = await this.alarmRepository.find({ + where: { user: { id: userId } }, + relations: ['user', 'stock'], + }); + return result.map((val) => new AlarmResponse(val)); + } + + async findByStockId(stockId: string, userId: number): Promise { + return await this.alarmRepository.find({ + where: { stock: { id: stockId }, user: { id: userId } }, + relations: ['user', 'stock'], + }); + } + + async findOne(id: number) { + const result = await this.alarmRepository.findOne({ + where: { id }, + relations: ['user', 'stock'], + }); + if (result) return new AlarmResponse(result); + else throw new NotFoundException('등록된 알림을 찾을 수 없습니다.'); + } + + async update(id: number, updateData: AlarmRequest) { + const alarm = await this.alarmRepository.findOne({ where: { id } }); + if (!alarm) { + throw new NotFoundException('등록된 알림을 찾을 수 없습니다.'); + } + + await this.alarmRepository.update(id, updateData); + const updatedAlarm = await this.alarmRepository.findOne({ + where: { id }, + relations: ['user', 'stock'], + }); + if (updatedAlarm) return new AlarmResponse(updatedAlarm); + else + throw new NotFoundException( + `${id} : 업데이트할 알림을 찾을 수 없습니다.`, + ); + } + + async delete(id: number) { + const alarm = await this.alarmRepository.findOne({ where: { id } }); + if (!alarm) { + throw new NotFoundException(`${id} : 삭제할 알림을 찾을 수 없습니다.`); + } + + await this.alarmRepository.delete(id); + } + + async sendPushNotification(alarm: Alarm): Promise { + const { user, stock, targetPrice, targetVolume } = alarm; + + const payload = { + title: '주식 알림', + body: `${stock.name}: ${ + targetPrice ? `가격이 ${targetPrice}에 도달했습니다.` : '' + } ${targetVolume ? `거래량이 ${targetVolume}에 도달했습니다.` : ''}`, + stockId: stock.id, + }; + + const subscriptions = await this.dataSource.manager.findBy( + PushSubscription, + { + user: { id: user.id }, + }, + ); + + for (const subscription of subscriptions) { + await this.pushService.sendPushNotification(subscription, payload); + } + } +} diff --git a/packages/backend/src/alarm/alarm.subscriber.ts b/packages/backend/src/alarm/alarm.subscriber.ts new file mode 100644 index 00000000..f5e15a6b --- /dev/null +++ b/packages/backend/src/alarm/alarm.subscriber.ts @@ -0,0 +1,62 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + DataSource, + EntitySubscriberInterface, + EventSubscriber, + InsertEvent, +} from 'typeorm'; +import { Logger } from 'winston'; +import { AlarmService } from './alarm.service'; +import { Alarm } from './domain/alarm.entity'; +import { StockMinutely } from '@/stock/domain/stockData.entity'; + +@Injectable() +@EventSubscriber() +export class AlarmSubscriber + implements EntitySubscriberInterface +{ + constructor( + private readonly datasource: DataSource, + private readonly alarmService: AlarmService, + @Inject('winston') private readonly logger: Logger, + ) { + this.datasource.subscribers.push(this); + } + + listenTo() { + return StockMinutely; + } + + isValidAlarm(alarm: Alarm, entity: StockMinutely) { + if (alarm.alarmDate && alarm.alarmDate > entity.createdAt) { + return false; + } else { + if (alarm.targetPrice && alarm.targetPrice >= entity.open) { + return true; + } + if (alarm.targetVolume && alarm.targetVolume >= entity.volume) { + return true; + } + return false; + } + } + + async afterInsert(event: InsertEvent) { + try { + const stockMinutely = event.entity; + const rawAlarms = await this.datasource.manager.find(Alarm, { + where: { stock: { id: stockMinutely.stock.id } }, + relations: ['user', 'stock'], + }); + + const alarms = rawAlarms.filter((val) => + this.isValidAlarm(val, stockMinutely), + ); + for (const alarm of alarms) { + await this.alarmService.sendPushNotification(alarm); + } + } catch (error) { + this.logger.warn(`Failed to handle alarm afterInsert event : ${error}`); + } + } +} diff --git a/packages/backend/src/alarm/domain/alarm.entity.ts b/packages/backend/src/alarm/domain/alarm.entity.ts new file mode 100644 index 00000000..4d33d215 --- /dev/null +++ b/packages/backend/src/alarm/domain/alarm.entity.ts @@ -0,0 +1,40 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Stock } from '@/stock/domain/stock.entity'; +import { User } from '@/user/domain/user.entity'; + +@Entity() +export class Alarm { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.alarms, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => Stock, (stock) => stock.alarms, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'stock_id' }) + stock: Stock; + + @Column({ type: 'int', name: 'target_price', nullable: true }) + targetPrice?: number; + + @Column({ type: 'bigint', name: 'target_volume', nullable: true }) + targetVolume?: number; + + @Column({ type: 'timestamp', name: 'alarm_date', nullable: true }) + alarmDate?: Date; + + @CreateDateColumn({ type: 'timestamp', name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp', name: 'updated_at' }) + updatedAt: Date; +} diff --git a/packages/backend/src/alarm/domain/subscription.entity.ts b/packages/backend/src/alarm/domain/subscription.entity.ts new file mode 100644 index 00000000..f3013973 --- /dev/null +++ b/packages/backend/src/alarm/domain/subscription.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { User } from '@/user/domain/user.entity'; + +@Entity() +export class PushSubscription { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne(() => User, (user) => user.subscriptions, { onDelete: 'CASCADE' }) + user: User; + + @Column({ type: 'text' }) + endpoint: string; + + @Column({ type: 'text' }) + p256dh: string; + + @Column({ type: 'text' }) + auth: string; +} diff --git a/packages/backend/src/alarm/dto/alarm.request.ts b/packages/backend/src/alarm/dto/alarm.request.ts new file mode 100644 index 00000000..9d8e5ad0 --- /dev/null +++ b/packages/backend/src/alarm/dto/alarm.request.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class AlarmRequest { + @ApiProperty({ + description: '주식 아이디', + example: '005930', + }) + stockId: string; + + @ApiProperty({ + description: '목표 가격', + example: 150.0, + required: false, + }) + targetPrice?: number; + + @ApiProperty({ + description: '목표 거래량', + example: 1000, + required: false, + }) + targetVolum?: number; + + @ApiProperty({ + description: '알림 종료 날짜', + example: '2024-12-01T00:00:00Z', + required: false, + }) + alarmDate?: Date; +} diff --git a/packages/backend/src/alarm/dto/alarm.response.ts b/packages/backend/src/alarm/dto/alarm.response.ts new file mode 100644 index 00000000..d2dc21fd --- /dev/null +++ b/packages/backend/src/alarm/dto/alarm.response.ts @@ -0,0 +1,56 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Alarm } from '../domain/alarm.entity'; + +export class AlarmResponse { + @ApiProperty({ + description: '알림 아이디', + example: 10, + }) + alarmId: number; + + @ApiProperty({ + description: '주식 코드', + example: '005930', + }) + stockId: string; + + @ApiProperty({ + description: '목표 주식 가격', + example: 50000, + nullable: true, + }) + targetPrice?: number; + + @ApiProperty({ + description: '목표 주식 거래량', + example: 10, + nullable: true, + }) + targetVolume?: number; + + @ApiProperty({ + description: '알림 만료일', + example: 10, + nullable: true, + }) + alarmDate?: Date; + + constructor(alarm: Alarm) { + this.alarmId = alarm.id; + this.stockId = alarm.stock.id; + this.targetPrice = alarm.targetPrice; + this.targetVolume = alarm.targetVolume; + this.alarmDate = alarm.alarmDate; + } +} + +export class AlarmSuccessResponse { + @ApiProperty({ + description: '성공 메시지', + example: 'success', + }) + message: string; + constructor(message: string) { + this.message = message; + } +} diff --git a/packages/backend/src/alarm/dto/subscribe.request.ts b/packages/backend/src/alarm/dto/subscribe.request.ts new file mode 100644 index 00000000..1ed453e0 --- /dev/null +++ b/packages/backend/src/alarm/dto/subscribe.request.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; +//import { User } from '@/user/domain/user.entity'; + +export class SubscriptionData { + //@ApiProperty({ type: () => User, description: '유저 아이디' }) + //user: User; + + @ApiProperty({ + type: 'string', + description: '엔드 포인트 설정', + }) + endpoint: string; + + @ApiProperty({ + type: 'object', + description: 'VAPID 키', + properties: { + p256dh: { type: 'string' }, + auth: { type: 'string' }, + }, + }) + keys: { + p256dh: string; + auth: string; + }; +} diff --git a/packages/backend/src/alarm/dto/subscribe.response.ts b/packages/backend/src/alarm/dto/subscribe.response.ts new file mode 100644 index 00000000..f6a9174d --- /dev/null +++ b/packages/backend/src/alarm/dto/subscribe.response.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SubscribeResponse { + @ApiProperty({ example: 'success', description: '성공 메시지' }) + message: string; +} diff --git a/packages/backend/src/alarm/push.controller.ts b/packages/backend/src/alarm/push.controller.ts new file mode 100644 index 00000000..991e187c --- /dev/null +++ b/packages/backend/src/alarm/push.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { SubscriptionData } from './dto/subscribe.request'; +import { SubscribeResponse } from './dto/subscribe.response'; +import { PushService } from './push.service'; +import SessionGuard from '@/auth/session/session.guard'; +import { GetUser } from '@/common/decorator/user.decorator'; +import { User } from '@/user/domain/user.entity'; + +@Controller('/push') +export class PushController { + constructor(private readonly pushService: PushService) {} + + @Post('subscribe') + @ApiOperation({ + summary: '알림 서비스 초기 설정', + description: '유저가 로그인할 때 알림을 받을 수 있게 초기설정한다.', + }) + @ApiResponse({ + status: 201, + description: '알림 초기설정', + type: SubscribeResponse, + }) + @UseGuards(SessionGuard) + async subscribe( + @Body() subscriptionData: SubscriptionData, + @GetUser() user: User, + ) { + const userId = user.id; + + return await this.pushService.createSubscription(userId, subscriptionData); + } +} diff --git a/packages/backend/src/alarm/push.service.ts b/packages/backend/src/alarm/push.service.ts new file mode 100644 index 00000000..ae61b8b5 --- /dev/null +++ b/packages/backend/src/alarm/push.service.ts @@ -0,0 +1,66 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { DataSource } from 'typeorm'; +import * as webPush from 'web-push'; +import { Logger } from 'winston'; +import { PushSubscription } from './domain/subscription.entity'; +import { SubscriptionData } from './dto/subscribe.request'; +import { SubscribeResponse } from './dto/subscribe.response'; + +@ApiTags('Push Notifications') +@Injectable() +export class PushService { + constructor( + @Inject('winston') private readonly logger: Logger, + private readonly dataSource: DataSource, + ) { + webPush.setVapidDetails( + 'mailto:noreply@juchum.info', + process.env.VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY!, + ); + } + + async sendPushNotification( + subscription: PushSubscription, + payload: object, + ): Promise { + const pushPayload = JSON.stringify(payload); + + try { + await webPush.sendNotification( + { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + }, + pushPayload, + ); + } catch (error) { + this.logger.warn( + `Fail to send message user id [${subscription.user.id}] : ${pushPayload}`, + error, + ); + } + } + + async createSubscription( + userId: number, + subscriptionData: SubscriptionData, + ): Promise { + const newSubscription = this.dataSource.manager.create(PushSubscription, { + user: { id: userId }, + endpoint: subscriptionData.endpoint, + p256dh: subscriptionData.keys.p256dh, + auth: subscriptionData.keys.auth, + }); + + await this.dataSource.manager.save(newSubscription); + const result: SubscribeResponse = { + message: 'Push subscription success', + }; + return result; + } +} diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index d9c69898..33b9f13a 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -15,7 +15,12 @@ const setCors = (app: INestApplication) => { 'http://localhost:5173', ], methods: '*', - allowedHeaders: '*', + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-Requested-With', + 'Accept', + ], credentials: true, }); }; diff --git a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts index 55bf9301..cecce968 100644 --- a/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiFluctuationData.api.ts @@ -26,7 +26,6 @@ export class OpenapiFluctuationData { setTimeout(() => this.getFluctuationRankStocks(), 1000); } - @Cron('* 9-15 * * 1-5') @Cron('*/1 9-15 * * 1-5') async getFluctuationRankStocks() { await this.getFluctuationRankFromApi(true); diff --git a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts index 437283d3..53b72d9c 100644 --- a/packages/backend/src/scraper/openapi/api/openapiToken.api.ts +++ b/packages/backend/src/scraper/openapi/api/openapiToken.api.ts @@ -1,4 +1,4 @@ -import { Global, Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { DataSource } from 'typeorm'; import { Logger } from 'winston'; @@ -7,7 +7,6 @@ import { openApiConfig } from '../config/openapi.config'; import { OpenapiException } from '../util/openapiCustom.error'; import { postOpenApi } from '../util/openapiUtil.api'; -@Global() @Injectable() export class OpenapiTokenApi { private config: (typeof openApiConfig)[] = []; diff --git a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts index 7a6784ae..f9bed60e 100644 --- a/packages/backend/src/scraper/openapi/openapi-scraper.module.ts +++ b/packages/backend/src/scraper/openapi/openapi-scraper.module.ts @@ -8,6 +8,7 @@ import { OpenapiPeriodData } from './api/openapiPeriodData.api'; import { OpenapiTokenApi } from './api/openapiToken.api'; import { LiveData } from './liveData.service'; import { OpenapiScraperService } from './openapi-scraper.service'; +import { WebsocketClient } from './websocket/websocketClient.websocket'; import { OpenapiFluctuationData } from '@/scraper/openapi/api/openapiFluctuationData.api'; import { OpenapiRankViewApi } from '@/scraper/openapi/api/openapiRankView.api'; import { @@ -54,6 +55,8 @@ import { StockLiveData } from '@/stock/domain/stockLiveData.entity'; OpenapiRankViewApi, OpenapiQueue, OpenapiConsumer, + WebsocketClient, + LiveData, ], exports: [LiveData], }) diff --git a/packages/backend/src/stock/domain/stock.entity.ts b/packages/backend/src/stock/domain/stock.entity.ts index 108cc993..82af0c2e 100644 --- a/packages/backend/src/stock/domain/stock.entity.ts +++ b/packages/backend/src/stock/domain/stock.entity.ts @@ -7,6 +7,7 @@ import { StockYearly, } from './stockData.entity'; import { StockLiveData } from './stockLiveData.entity'; +import { Alarm } from '@/alarm/domain/alarm.entity'; import { Like } from '@/chat/domain/like.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { FluctuationRankStock } from '@/stock/domain/FluctuationRankStock.entity'; @@ -61,4 +62,7 @@ export class Stock { (fluctuationRankStock) => fluctuationRankStock.stock, ) fluctuationRankStocks?: FluctuationRankStock[]; + + @OneToMany(() => Alarm, (alarm) => alarm.stock) + alarms?: Alarm[]; } diff --git a/packages/backend/src/stock/stock.module.ts b/packages/backend/src/stock/stock.module.ts index 673dbc56..2a374e66 100644 --- a/packages/backend/src/stock/stock.module.ts +++ b/packages/backend/src/stock/stock.module.ts @@ -24,8 +24,9 @@ import { import { StockDetailService } from './stockDetail.service'; import { StockLiveDataSubscriber } from './stockLiveData.subscriber'; import { StockRateIndexService } from './stockRateIndex.service'; +import { AlarmModule } from '@/alarm/alarm.module'; +import { Alarm } from '@/alarm/domain/alarm.entity'; import { ScraperModule } from '@/scraper/scraper.module'; - @Module({ imports: [ TypeOrmModule.forFeature([ @@ -37,7 +38,9 @@ import { ScraperModule } from '@/scraper/scraper.module'; StockYearly, StockLiveData, StockDetail, + Alarm, ]), + AlarmModule, ScraperModule, ], controllers: [StockController], diff --git a/packages/backend/src/stock/stockLiveData.subscriber.ts b/packages/backend/src/stock/stockLiveData.subscriber.ts index f44ffe35..f22315be 100644 --- a/packages/backend/src/stock/stockLiveData.subscriber.ts +++ b/packages/backend/src/stock/stockLiveData.subscriber.ts @@ -39,7 +39,9 @@ export class StockLiveDataSubscriber this.stockGateway.onUpdateStock(stockId, price, change, volume); } catch (error) { - this.logger.warn(`Failed to handle afterInsert event : ${error}`); + this.logger.warn( + `Failed to handle stock live data afterInsert event : ${error}`, + ); } } @@ -55,6 +57,7 @@ export class StockLiveDataSubscriber changeRate: change, volume: volume, } = updatedStockLiveData; + this.stockGateway.onUpdateStock(stockId, price, change, volume); } else { this.logger.warn( @@ -62,7 +65,9 @@ export class StockLiveDataSubscriber ); } } catch (error) { - this.logger.warn(`Failed to handle afterUpdate event : ${error}`); + this.logger.warn( + `Failed to handle stock live data afterUpdate event : ${error}`, + ); } } diff --git a/packages/backend/src/user/domain/user.entity.ts b/packages/backend/src/user/domain/user.entity.ts index 91a02c0b..f5d53276 100644 --- a/packages/backend/src/user/domain/user.entity.ts +++ b/packages/backend/src/user/domain/user.entity.ts @@ -5,6 +5,8 @@ import { OneToMany, PrimaryGeneratedColumn, } from 'typeorm'; +import { Alarm } from '@/alarm/domain/alarm.entity'; +import { PushSubscription } from '@/alarm/domain/subscription.entity'; import { Mention } from '@/chat/domain/mention.entity'; import { DateEmbedded } from '@/common/dateEmbedded.entity'; import { UserStock } from '@/stock/domain/userStock.entity'; @@ -45,6 +47,12 @@ export class User { @OneToMany(() => UserStock, (userStock) => userStock.user) userStocks: UserStock[]; + @OneToMany(() => Alarm, (alarm) => alarm.user) + alarms: Alarm[]; + + @OneToMany(() => PushSubscription, (subscription) => subscription.user) + subscriptions: PushSubscription[]; + @OneToMany(() => Mention, (mention) => mention.user) mentions: Mention[]; } diff --git a/yarn.lock b/yarn.lock index f1be2c17..bd4f38fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2286,6 +2286,13 @@ resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.12.2.tgz#760329e756e18a4aab82fc502b51ebdfebbe49f5" integrity sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA== +"@types/web-push@^3.6.4": + version "3.6.4" + resolved "https://registry.yarnpkg.com/@types/web-push/-/web-push-3.6.4.tgz#4c6e10d3963ba51e7b4b8fff185f43612c0d1346" + integrity sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ== + dependencies: + "@types/node" "*" + "@types/ws@^8.5.13": version "8.5.13" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.13.tgz#6414c280875e2691d0d1e080b05addbf5cb91e20" @@ -2666,6 +2673,13 @@ acorn@^8.11.0, acorn@^8.14.0, acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.2, acorn@^8 resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== +agent-base@^7.0.2: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + ajv-formats@2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -2912,6 +2926,16 @@ asap@^2.0.0: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +asn1.js@^5.3.0: + version "5.4.1" + resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07" + integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA== + dependencies: + bn.js "^4.0.0" + inherits "^2.0.1" + minimalistic-assert "^1.0.0" + safer-buffer "^2.1.0" + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -3088,6 +3112,11 @@ bluebird@~3.7.2: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +bn.js@^4.0.0: + version "4.12.1" + resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.1.tgz#215741fe3c9dba2d7e12c001d0cfdbae43975ba7" + integrity sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg== + body-parser@1.20.3: version "1.20.3" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.3.tgz#1953431221c6fb5cd63c4b36d53fab0928e548c6" @@ -3157,6 +3186,11 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" +buffer-equal-constant-time@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" + integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== + buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" @@ -3810,6 +3844,13 @@ debug@2.6.9: dependencies: ms "2.0.0" +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -3817,13 +3858,6 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== - dependencies: - ms "^2.1.3" - dedent@0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -4008,6 +4042,13 @@ eastasianwidth@^0.2.0: resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== +ecdsa-sig-formatter@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" + integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== + dependencies: + safe-buffer "^5.0.1" + editor@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/editor/-/editor-1.0.0.tgz#60c7f87bd62bcc6a894fa8ccd6afb7823a24f742" @@ -5286,6 +5327,19 @@ http-errors@2.0.0: statuses "2.0.1" toidentifier "1.0.1" +http_ece@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http_ece/-/http_ece-1.2.0.tgz#84d5885f052eae8c9b075eee4d2eb5105f114479" + integrity sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA== + +https-proxy-agent@^7.0.0: + version "7.0.5" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" + integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== + dependencies: + agent-base "^7.0.2" + debug "4" + human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -6252,6 +6306,23 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== +jwa@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc" + integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA== + dependencies: + buffer-equal-constant-time "1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + +jws@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4" + integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg== + dependencies: + jwa "^2.0.0" + safe-buffer "^5.0.1" + keyv@^4.5.3, keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -6615,6 +6686,11 @@ min-indent@^1.0.0, min-indent@^1.0.1: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== +minimalistic-assert@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" + integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -6641,7 +6717,7 @@ minimist@1.2.7: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== -minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: +minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -7716,7 +7792,7 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== -"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -8833,6 +8909,17 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-push@^3.6.7: + version "3.6.7" + resolved "https://registry.yarnpkg.com/web-push/-/web-push-3.6.7.tgz#5f5e645951153e37ef90a6ddea5c150ea0f709e1" + integrity sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A== + dependencies: + asn1.js "^5.3.0" + http_ece "1.2.0" + https-proxy-agent "^7.0.0" + jws "^4.0.0" + minimist "^1.2.5" + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"