Skip to content

Commit

Permalink
[ST-648] FCM 푸시 메시지 (#71)
Browse files Browse the repository at this point in the history
* ⭐ FCM 푸시

* 🔨 FCM 토큰 DTO

* 🔨 user/fcmToken Bearer Auth

* 🔨 수신자 미접속시 푸시 안가는 버그 수정

* 🔨 FCM notification > data

* 🔨 텍스트 메시지 송신시 본문만 보냄

* 🔨 FCM 푸시 data.type 추가
  • Loading branch information
w8385 authored Oct 3, 2023
1 parent 884101b commit c9737aa
Show file tree
Hide file tree
Showing 10 changed files with 1,589 additions and 120 deletions.
1,459 changes: 1,406 additions & 53 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"discord-webhook-node": "^1.1.8",
"dotenv": "^16.3.1",
"dynamoose": "^3.2.0",
"firebase-admin": "^11.11.0",
"multer": "^1.4.5-lts.1",
"nestjs-dynamoose": "^0.5.5",
"redis": "^4.6.8",
Expand Down
9 changes: 9 additions & 0 deletions src/config.firebase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { credential } from 'firebase-admin';
import { initializeApp } from 'firebase-admin/app';
import * as process from 'process';

export const configFirebase = () => {
initializeApp({
credential: credential.cert(process.env.GOOGLE_APPLICATION_CREDENTIALS),
});
};
3 changes: 3 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AppModule } from './app.module';
import { configFirebase } from './config.firebase';
import { configSwagger } from './config.swagger';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
Expand All @@ -11,8 +12,10 @@ async function bootstrap() {
app.use(json({ limit: '50mb' }));
app.use(urlencoded({ limit: '50mb', extended: false }));
app.useGlobalPipes(new ValidationPipe({ transform: true }));

dotenv.config();
configSwagger(app);
configFirebase();

await app.listen(3000);
}
Expand Down
4 changes: 2 additions & 2 deletions src/redis/redis.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { redisPubProvider, redisSubProvider } from '../config.redis';
import { redisPubProvider } from '../config.redis';
import { RedisRepository } from './redis.repository';
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
Expand All @@ -10,7 +10,7 @@ import { Module } from '@nestjs/common';
isGlobal: true,
}),
],
providers: [RedisRepository, redisPubProvider, redisSubProvider],
providers: [RedisRepository, redisPubProvider],
exports: [RedisRepository],
})
export class RedisModule {}
109 changes: 86 additions & 23 deletions src/redis/redis.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export class RedisRepository {
constructor(
@Inject(CACHE_MANAGER) private cache: Cache,
@Inject('REDIS_PUB') private redisPub: RedisClientType,
@Inject('REDIS_SUB') private redisSub: RedisClientType,
) {}

/*
Expand All @@ -30,6 +29,44 @@ export class RedisRepository {
}
}

async hSet(key: string, value: any) {
if (process.env.NODE_ENV === 'local') {
// 로컬 캐시엔 해시맵 없나?
} else {
await this.redisPub.hSet(key, value);
}
}

async setSocketId(key: string, value: any) {
if (process.env.NODE_ENV === 'local') {
// 로컬 캐시엔 해시맵 없나?
} else {
await this.redisPub.hSet(key, {
socketId: value,
});
}
}

async setFCMToken(key: string, value: any) {
if (process.env.NODE_ENV === 'local') {
// 로컬 캐시엔 해시맵 없나?
} else {
await this.redisPub.hSet(key, {
fcmToken: value,
});
}
}

async setRole(key: string, value: any) {
if (process.env.NODE_ENV === 'local') {
// 로컬 캐시엔 해시맵 없나?
} else {
await this.redisPub.hSet(key, {
role: value,
});
}
}

async get(key: string): Promise<any> {
if (process.env.NODE_ENV === 'local') {
return await this.cache.get(key);
Expand All @@ -38,6 +75,38 @@ export class RedisRepository {
}
}

async hGetAll(key: string): Promise<any> {
if (process.env.NODE_ENV === 'local') {
// 로컬 캐시엔 해시맵 없나?
} else {
return await this.redisPub.hGetAll(key);
}
}

async getSocketId(key: string): Promise<any> {
if (process.env.NODE_ENV === 'local') {
// 로컬 캐시엔 해시맵 없나?
} else {
return await this.redisPub.hGet(key, 'socketId');
}
}

async getFCMToken(key: string): Promise<any> {
if (process.env.NODE_ENV === 'local') {
// 로컬 캐시엔 해시맵 없나?
} else {
return await this.redisPub.hGet(key, 'fcmToken');
}
}

async getRole(key: string): Promise<any> {
if (process.env.NODE_ENV === 'local') {
// 로컬 캐시엔 해시맵 없나?
} else {
return await this.redisPub.hGet(key, 'role');
}
}

async getAllKeys() {
if (process.env.NODE_ENV === 'local') {
return await this.cache.store.keys();
Expand All @@ -54,6 +123,22 @@ export class RedisRepository {
}
}

async delAll() {
if (process.env.NODE_ENV === 'local') {
await this.cache.reset();
} else {
await this.redisPub.flushAll();
}
}

async delSocketId(key: string) {
if (process.env.NODE_ENV === 'local') {
// 로컬 캐시엔 해시맵 없나?
} else {
await this.redisPub.hDel(key, 'socketId');
}
}

async push(key: string, value: any) {
if (process.env.NODE_ENV === 'local') {
const currentValue: Array<any> = await this.cache.get(key);
Expand All @@ -75,26 +160,4 @@ export class RedisRepository {
await this.redisPub.publish(channel, message);
}
}

/*
REDIS_SUB
*/
async subscribe(channel: string) {
if (process.env.NODE_ENV === 'local') {
// 로컬 Pub/Sub은 게이트웨이 단에서 구현함
} else {
await this.redisSub.subscribe(channel, (message) => {
console.log('message : ', message);
console.log(this.server.sockets.adapter.rooms);
});
}
}

async unsubscribe(channel: string) {
if (process.env.NODE_ENV === 'local') {
// 로컬 Pub/Sub은 게이트웨이 단에서 구현함
} else {
await this.redisSub.unsubscribe(channel);
}
}
}
92 changes: 50 additions & 42 deletions src/socket/socket.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { getMessaging } from 'firebase-admin/messaging';
import * as process from 'process';
import { RedisClientType } from 'redis';
import { Server } from 'socket.io';
Expand All @@ -35,7 +36,7 @@ export class SocketGateway {
client.handshake.headers,
);

await this.redisRepository.set(user.id, client.id);
await this.redisRepository.setSocketId(user.id, client.id);
if (process.env.NODE_ENV === 'dev') {
await this.redisSub.subscribe(client.id, (message) => {
client.emit('message', message);
Expand Down Expand Up @@ -63,30 +64,17 @@ export class SocketGateway {
client.handshake.headers,
);

await this.redisRepository.del(user.id);
await this.redisRepository.unsubscribe(client.id);
await this.redisRepository.delSocketId(user.id);
await this.redisSub.unsubscribe(client.id);
} catch (error) {
const message = `소켓 연결을 끊을 수 없습니다. ${error.message}`;

console.log(message);
await socketErrorWebhook.send(message);

return new Error('소켓 연결을 끊을 수 없습니다.');
}
}

//
// /**
// * 원하는 역할의 온라인 사용자 목록을 가져오는 메소드
// * @param client
// * @param payload
// */
// @SubscribeMessage('get-role-participants')
// async getRoleParticipants(client: any, payload: any) {
// const { role } = payload;
// return await this.redisRepository.get(role);
// }

/**
* 원하는 사용자의 정보를 가져오는 메소드
* @param client
Expand All @@ -100,7 +88,6 @@ export class SocketGateway {
} catch (error) {
const message = `사용자 정보를 가져올 수 없습니다. ${error.message}`;

console.log(message);
await socketErrorWebhook.send(message);

return new Error('사용자 정보를 가져올 수 없습니다.');
Expand All @@ -116,36 +103,57 @@ export class SocketGateway {
async handleMessage(client: any, payload: any) {
const { receiverId, chattingId, format, body } = payload;

try {
// 메시지를 보낸 사용자의 정보를 가져옴
const sender = await this.socketRepository
.getUserFromAuthorization(client.handshake.headers)
.then((user) => user.id);

// user에게 메시지 전송
await this.sendMessageToUser(
sender,
receiverId,
chattingId,
format,
body,
);
} catch (error) {
const message = `메시지를 전송할 수 없습니다. ${error.message}`;
const sender = await this.socketRepository.getUserFromAuthorization(
client.handshake.headers,
);
if (sender === null) {
const message = `사용자를 찾을 수 없습니다.`;

await socketErrorWebhook.send(message);

return new Error('메시지를 전송할 수 없습니다.');
return new Error('사용자를 찾을 수 없습니다.');
}

// 푸시 알림 보내기
const senderName = sender.name;
const senderProfileImage = sender.profileImage;
const receiverFCMToken = await this.redisRepository.getFCMToken(receiverId);

// 푸시 알림 전송
await getMessaging().send({
data: {
imageUrl: senderProfileImage,
title: senderName,
body:
format === 'text'
? JSON.parse(body).text
: '새로운 메시지가 도착했습니다.',
type: 'message',
},
token: receiverFCMToken,
});

// 소켓 메시지 전송
// 메시지를 보낸 사용자의 정보를 가져옴
const senderId = sender.id;

// user에게 메시지 전송
await this.sendMessageToUser(
senderId,
receiverId,
chattingId,
format,
body,
);
}

/**
* 다른 사용자에게 메시지를 전송하는 메소드
* @param senderId : 메시지를 보내는 사용자의 ID
* @param receiverId : 메시지를 받는 사용자의 ID
* @param chattingId : 메시지를 보내는 채팅방의 ID
* @param format : 메시지의 형식 (text, appoint-request , ...)
* @param body : 메시지의 내용 (JSON 형식 ex: { "text" : "안녕하세요" } )
* @param senderId 메시지를 보내는 사용자의 ID
* @param receiverId 메시지를 받는 사용자의 ID
* @param chattingId 메시지를 보내는 채팅방의 ID
* @param format 메시지의 형식 (text, appoint-request , ...)
* @param body 메시지의 내용 (JSON 형식 ex: { "text" : "안녕하세요" } )
*/
async sendMessageToUser(
senderId: string,
Expand All @@ -160,7 +168,7 @@ export class SocketGateway {
body,
createdAt: new Date().toISOString(),
};
const receiverSocketId = await this.redisRepository.get(receiverId);
const receiverSocketId = await this.redisRepository.getSocketId(receiverId);
if (receiverSocketId != null) {
this.sendMessageToSocketClient(receiverSocketId, chattingId, message);
} else {
Expand Down Expand Up @@ -195,15 +203,15 @@ export class SocketGateway {
body,
createdAt: new Date().toISOString(),
};
const receiverSocketId = await this.redisRepository.get(receiverId);
const receiverSocketId = await this.redisRepository.getSocketId(receiverId);

if (receiverSocketId != null) {
this.sendMessageToSocketClient(receiverSocketId, chattingId, message);
} else {
console.log('receiver is not online', receiverId);
//TODO: FCM 메시지 보내기
}
const senderSocketId = await this.redisRepository.get(senderId);
const senderSocketId = await this.redisRepository.getSocketId(senderId);
if (senderSocketId != null) {
this.sendMessageToSocketClient(senderSocketId, chattingId, message);
} else {
Expand Down
9 changes: 9 additions & 0 deletions src/user/dto/setFCMToken-user.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';

export class SetFCMTokenUserDto {
@ApiProperty({
description: 'FCM 토큰',
default: 'FCM_TOKEN',
})
fcmToken: string;
}
14 changes: 14 additions & 0 deletions src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { UserParam } from './descriptions/user.param';
import { UserResponse } from './descriptions/user.response';
import { CreateStudentDto, CreateTeacherDto } from './dto/create-user.dto';
import { LoginUserDto } from './dto/login-user.dto';
import { SetFCMTokenUserDto } from './dto/setFCMToken-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UserService } from './user.service';
import { Body, Controller, Get, Headers, Param, Post } from '@nestjs/common';
Expand Down Expand Up @@ -143,4 +144,17 @@ export class UserController {
getOnlineTeachers(@Headers() headers: Headers) {
return this.userService.getOnlineTeachers();
}

@ApiTags('User')
@ApiBearerAuth('Authorization')
@Post('user/fcmToken')
setFCMToken(
@Headers() headers: Headers,
@Body() setFCMTokenUserDto: SetFCMTokenUserDto,
) {
return this.userService.setFCMToken(
AccessToken.userId(headers),
setFCMTokenUserDto.fcmToken,
);
}
}
Loading

0 comments on commit c9737aa

Please sign in to comment.