Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/#12 - 알림 구현 #313

Merged
merged 36 commits into from
Dec 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
290c5ab
✨ feat: 알람 엔티티 추가 및 모듈 뼈대 구현
demian-m00n Nov 19, 2024
57cee7f
✨ feat: 알람 CRUD 기능 구현
demian-m00n Nov 19, 2024
3b222d2
✨ feat: 알람 요청 DTO 추가
demian-m00n Nov 20, 2024
2fca19d
✨ feat: 유저 ID, 주식 ID 기반 알람 리스트 조회 기능 추가
demian-m00n Nov 20, 2024
fa22d83
✨ feat: subscription 엔티티 추가
demian-m00n Nov 20, 2024
bcf7acc
✨ feat: 알람 매칭 서브스크라이버 추가
demian-m00n Nov 20, 2024
a5caa36
✨ feat: 웹 푸시 서비스 구현
demian-m00n Nov 21, 2024
0b7cfba
🚚 chore: web-push 의존성 설치
demian-m00n Nov 21, 2024
24a7843
✨ feat: 웹 푸시 서비스 구현
demian-m00n Nov 21, 2024
3429cee
✨ feat: 푸시 구독 기능 구현
demian-m00n Nov 25, 2024
4b4e923
💄 style: merge dev-be, 알림 서비스 인수인계
swkim12345 Nov 27, 2024
6557b32
Merge branch 'dev-be' into feature/#12
swkim12345 Nov 28, 2024
6812061
♻️ refactor: 의존성 수정
swkim12345 Nov 28, 2024
cc3e1c6
💄 style: discribe todo 추가
swkim12345 Nov 28, 2024
a87b719
♻️ refactor: token의 경우 global하게 필요 없으므로 데코레이터 삭제
swkim12345 Nov 28, 2024
db5a481
♻️ refactor: alarm service와 서비스 의존 해결
swkim12345 Nov 28, 2024
5110889
♻️ refactor: live data 모듈간 의존성 리팩토링
swkim12345 Nov 28, 2024
43af7e6
💄 style: 불필요한 주석 삭제
swkim12345 Nov 29, 2024
4f54cec
💄 style: 테스트 코드 삭제
swkim12345 Nov 29, 2024
39267aa
✨ feat: push 서비스 완성 및 dto 추가
swkim12345 Nov 30, 2024
cb4a2ce
📝 docs: alarm request swagger 추가
swkim12345 Nov 30, 2024
af61d20
📝 docs: 알림 swagger 추가
swkim12345 Nov 30, 2024
bebbb0a
🐛 fix: 알람 모듈 수정, swagger 추가
swkim12345 Nov 30, 2024
47ae8b8
🐛 fix: push 모듈 수정 및 swagger 추가
swkim12345 Nov 30, 2024
1ad805f
💄 style: merge from dev-be
swkim12345 Nov 30, 2024
480c6e1
🐛 fix: localhost cors에러 해결. 이건...
swkim12345 Dec 1, 2024
d0affc6
✨ feat: subscriber 추가
swkim12345 Dec 1, 2024
00a4b41
🐛 fix: userId가 저장되지 않는 문제 해결
swkim12345 Dec 1, 2024
a6fc5b8
🐛 fix: userId를 명시적으로받지 않음. swagger 보강
swkim12345 Dec 1, 2024
7e3a2b3
📝 docs: swagger param 애매한 거 수정
swkim12345 Dec 1, 2024
1beebb5
📝 docs: live data logger 추가
swkim12345 Dec 1, 2024
04b411c
🐛 fix: alarm subscribe 찾지 못해 보내지 못하는 에러 수정
swkim12345 Dec 1, 2024
f98e749
Merge branch 'dev-be' into feature/#12
swkim12345 Dec 1, 2024
7a802f7
📝 docs: alarm response 추가, subscribe response 중 필요 없는 거 삭제
swkim12345 Dec 1, 2024
6a8ada6
📝 docs: swagger message response 추가
swkim12345 Dec 1, 2024
ec589ea
🐛 fix: subscriber 조건 변경
swkim12345 Dec 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand Down
145 changes: 145 additions & 0 deletions packages/backend/src/alarm/alarm.controller.ts
Original file line number Diff line number Diff line change
@@ -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<AlarmResponse> {
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<AlarmResponse> {
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<AlarmResponse> {
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);
}
}
17 changes: 17 additions & 0 deletions packages/backend/src/alarm/alarm.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
115 changes: 115 additions & 0 deletions packages/backend/src/alarm/alarm.service.ts
Original file line number Diff line number Diff line change
@@ -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<Alarm>,
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<Alarm[]> {
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) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

유저가 주인인지 확인하는 과정이 필요하다고 생각합니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 감사합니다! 이거 추가해서 올릴게요!

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<void> {
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);
}
}
}
62 changes: 62 additions & 0 deletions packages/backend/src/alarm/alarm.subscriber.ts
Original file line number Diff line number Diff line change
@@ -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<StockMinutely>
{
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;
Comment on lines +33 to +35
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else가 없어도 될 것 같아요!

}
if (alarm.targetVolume && alarm.targetVolume >= entity.volume) {
return true;
}
return false;
}
}

async afterInsert(event: InsertEvent<StockMinutely>) {
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}`);
}
}
}
40 changes: 40 additions & 0 deletions packages/backend/src/alarm/domain/alarm.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading