diff --git a/BE/.gitignore b/BE/.gitignore index f173622..d82dac4 100644 --- a/BE/.gitignore +++ b/BE/.gitignore @@ -179,5 +179,4 @@ lerna-debug.log* /envs /logs -firebase.json - +village-2ed97-firebase-adminsdk-1axqh-408a2ee88b.json \ No newline at end of file diff --git a/BE/package-lock.json b/BE/package-lock.json index 687f7f5..45470d2 100644 --- a/BE/package-lock.json +++ b/BE/package-lock.json @@ -36,7 +36,7 @@ "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "typeorm": "^0.3.17", - "uuidv4": "^6.2.13", + "uuid": "^9.0.1", "winston": "^3.11.0", "winston-daily-rotate-file": "^4.7.1", "ws": "^8.14.2" @@ -2694,6 +2694,14 @@ "reflect-metadata": "^0.1.13" } }, + "node_modules/@nestjs/config/node_modules/uuid": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", + "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/core": { "version": "10.2.8", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.2.8.tgz", @@ -2920,18 +2928,6 @@ "typeorm": "^0.3.0" } }, - "node_modules/@nestjs/typeorm/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@nestjs/websockets": { "version": "10.2.10", "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.2.10.tgz", @@ -4126,11 +4122,6 @@ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" }, - "node_modules/@types/uuid": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", - "integrity": "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==" - }, "node_modules/@types/validator": { "version": "13.11.7", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.7.tgz", @@ -12496,26 +12487,13 @@ } }, "node_modules/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/uuidv4": { - "version": "6.2.13", - "resolved": "https://registry.npmjs.org/uuidv4/-/uuidv4-6.2.13.tgz", - "integrity": "sha512-AXyzMjazYB3ovL3q051VLH06Ixj//Knx7QnUSi1T//Ie3io6CpsPu9nVMOx5MoLWh6xV0B9J0hIaxungxXUbPQ==", - "dependencies": { - "@types/uuid": "8.3.4", - "uuid": "8.3.2" - } - }, - "node_modules/uuidv4/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/BE/package.json b/BE/package.json index 015974c..86f3f76 100644 --- a/BE/package.json +++ b/BE/package.json @@ -47,7 +47,7 @@ "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "typeorm": "^0.3.17", - "uuidv4": "^6.2.13", + "uuid": "^9.0.1", "winston": "^3.11.0", "winston-daily-rotate-file": "^4.7.1", "ws": "^8.14.2" diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index f64a3d1..dc53f2e 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -16,6 +16,8 @@ import { ChatModule } from './chat/chat.module'; import { CacheModule } from '@nestjs/cache-manager'; import { RedisConfigProvider } from './config/redis.config'; import { ReportModule } from './report/report.module'; +import { ImageModule } from './image/image.module'; +import { NotificationModule } from './notification/notification.module'; @Module({ imports: [ @@ -40,6 +42,8 @@ import { ReportModule } from './report/report.module'; LoginModule, ChatModule, ReportModule, + ImageModule, + NotificationModule, ], controllers: [AppController], providers: [ diff --git a/BE/src/chat/chat.controller.ts b/BE/src/chat/chat.controller.ts index d86f6c9..62c48e8 100644 --- a/BE/src/chat/chat.controller.ts +++ b/BE/src/chat/chat.controller.ts @@ -4,7 +4,9 @@ import { Get, HttpException, Param, + Patch, Post, + Put, UseGuards, } from '@nestjs/common'; import { ChatService } from './chat.service'; @@ -52,6 +54,12 @@ export class ChatController { return await this.chatService.unreadChat(userId); } + @Patch('leave/:id') + @UseGuards(AuthGuard) + async leaveChatRoom(@Param('id') id: number, @UserHash() userId: string) { + return await this.chatService.leaveChatRoom(id, userId); + } + @Get() async testPush(@Body() body) { await this.fcmHandler.sendPush(body.user, { diff --git a/BE/src/chat/chat.service.ts b/BE/src/chat/chat.service.ts index 9796a9d..9000c46 100644 --- a/BE/src/chat/chat.service.ts +++ b/BE/src/chat/chat.service.ts @@ -40,7 +40,16 @@ export class ChatService { chat.chat_room = message.room_id; chat.is_read = is_read; chat.count = message.count; - await this.chatRepository.save(chat); + const lastChat = await this.chatRepository.save(chat); + + await this.chatRoomRepository.update( + { + id: message.room_id, + }, + { + last_chat_id: lastChat.id, + }, + ); } async createRoom( @@ -79,24 +88,12 @@ export class ChatService { async findRoomList(userId: string) { const chatListInfo = { all_read: true, chat_list: [] }; - const subquery = this.chatRepository - .createQueryBuilder('chat') - .select('chat.id', 'id') - .addSelect('chat.chat_room', 'chat_room') - .addSelect('chat.message', 'message') - .addSelect('chat.create_date', 'create_date') - .addSelect('chat.is_read', 'is_read') - .addSelect('chat.sender', 'sender') - .where( - 'chat.id IN (SELECT MAX(chat.id) FROM chat GROUP BY chat.chat_room)', - ); - const rooms = await this.chatRoomRepository .createQueryBuilder('chat_room') .innerJoin( - '(' + subquery.getQuery() + ')', + 'chat_room.lastChat', 'chat_info', - 'chat_room.id = chat_info.chat_room', + 'chat_room.lastChat = chat_info.id', ) .innerJoin( 'chat_room.writerUser', @@ -126,7 +123,9 @@ export class ChatService { 'chat_info.sender as sender', ]) .where('chat_room.writer = :userId', { userId: userId }) + .andWhere('chat_room.writer_hide IS false') .orWhere('chat_room.user = :userId', { userId: userId }) + .andWhere('chat_room.user_hide IS false') .orderBy('chat_info.create_date', 'DESC') .getRawMany(); @@ -160,24 +159,12 @@ export class ChatService { } async unreadChat(userId: string) { - const subquery = this.chatRepository - .createQueryBuilder('chat') - .select('chat.id', 'id') - .addSelect('chat.chat_room', 'chat_room') - .addSelect('chat.message', 'message') - .addSelect('chat.create_date', 'create_date') - .addSelect('chat.is_read', 'is_read') - .addSelect('chat.sender', 'sender') - .where( - 'chat.id IN (SELECT MAX(chat.id) FROM chat GROUP BY chat.chat_room)', - ); - const rooms = await this.chatRoomRepository .createQueryBuilder('chat_room') .innerJoin( - '(' + subquery.getQuery() + ')', + 'chat_room.lastChat', 'chat_info', - 'chat_room.id = chat_info.chat_room', + 'chat_room.lastChat = chat_info.id', ) .innerJoin( 'chat_room.writerUser', @@ -204,7 +191,9 @@ export class ChatService { 'chat_info.sender as sender', ]) .where('chat_room.writer = :userId', { userId: userId }) + .andWhere('chat_room.writer_hide IS false') .orWhere('chat_room.user = :userId', { userId: userId }) + .andWhere('chat_room.user_hide IS false') .orderBy('chat_info.create_date', 'DESC') .getRawMany(); @@ -217,7 +206,7 @@ export class ChatService { return { all_read: true }; } - async findRoomById(roomId: number, userId: string) { + async makeAllRead(roomId: number, userId: string) { await this.chatRepository .createQueryBuilder('chat') .update() @@ -226,15 +215,57 @@ export class ChatService { .andWhere('chat.is_read = :isRead', { isRead: false }) .andWhere('chat.sender != :userId', { userId: userId }) .execute(); + } - const room = await this.chatRoomRepository.findOne({ + /*async getRoomAndChatInfoPagination(roomId: number, chatId: number) { + return await this.chatRoomRepository + .createQueryBuilder('chat_room') + .innerJoinAndSelect('chat_room.chats', 'chat_info') + .innerJoinAndSelect('chat_room.writerUser', 'writer') + .innerJoinAndSelect('chat_room.userUser', 'user') + .where('chat_room.id = :roomId', { roomId: roomId }) + .andWhere('chat_info.id < :chatId', { chatId: chatId }) + .orderBy('chat_info.id', 'DESC') + .limit(30) + .getOne(); + }*/ + + async getRoomAndChatInfo(roomId: number, userId: string) { + return await this.chatRoomRepository.findOne({ where: { id: roomId, }, relations: ['chats', 'userUser', 'writerUser'], }); + } + + async findRoomById(roomId: number, userId: string) { + await this.makeAllRead(roomId, userId); + + const room = await this.getRoomAndChatInfo(roomId, userId); this.checkAuth(room, userId); + + let chats = room.chats; + + if ( + room.writer === userId && + room.writer_hide === false && + room.writer_left_time !== null + ) { + chats = chats.filter((chat) => { + return chat.create_date > room.writer_left_time; + }); + } else if ( + room.user === userId && + room.user_hide === false && + room.user_left_time !== null + ) { + chats = chats.filter((chat) => { + return chat.create_date > room.user_left_time; + }); + } + return { writer: room.writer, writer_profile_img: @@ -247,7 +278,7 @@ export class ChatService { ? this.configService.get('DEFAULT_PROFILE_IMAGE') : room.userUser.profile_img, post_id: room.post_id, - chat_log: room.chats, + chat_log: chats, }; } @@ -256,6 +287,10 @@ export class ChatService { throw new HttpException('존재하지 않는 채팅방입니다.', 404); } else if (room.writer !== userId && room.user !== userId) { throw new HttpException('권한이 없습니다.', 403); + } else if (room.writer === userId && room.writer_hide === true) { + throw new HttpException('숨긴 채팅방입니다.', 403); + } else if (room.user === userId && room.user_hide === true) { + throw new HttpException('숨긴 채팅방입니다.', 403); } } @@ -264,6 +299,7 @@ export class ChatService { where: { id: message.room_id }, relations: ['writerUser', 'userUser'], }); + const receiver: UserEntity = chatRoom.writerUser.user_hash === message.sender ? chatRoom.userUser @@ -280,6 +316,19 @@ export class ChatService { await this.fcmHandler.sendPush(receiver.user_hash, pushMessage); } + async checkOpponentLeft(roomId: number, userId: string) { + const room = await this.chatRoomRepository.findOne({ + where: { id: roomId }, + }); + + if (room.writer === userId && room.user_hide !== false) { + room.user_hide = false; + } else if (room.user === userId && room.writer_hide !== false) { + room.writer_hide = false; + } + await this.chatRoomRepository.save(room); + } + validateUser(authorization) { try { const payload: Payload = jwt.verify( @@ -292,4 +341,20 @@ export class ChatService { return null; } } + + async leaveChatRoom(roomId: number, userId: string) { + const room = await this.chatRoomRepository.findOne({ + where: { id: roomId }, + }); + + if (room.writer === userId) { + room.writer_hide = true; + room.writer_left_time = new Date(); + } else if (room.user === userId) { + room.user_hide = true; + room.user_left_time = new Date(); + } + + await this.chatRoomRepository.save(room); + } } diff --git a/BE/src/chat/chats.gateway.ts b/BE/src/chat/chats.gateway.ts index df7f8d0..fc3184c 100644 --- a/BE/src/chat/chats.gateway.ts +++ b/BE/src/chat/chats.gateway.ts @@ -84,6 +84,9 @@ export class ChatsGateway implements OnGatewayConnection, OnGatewayDisconnect { ); }); } + + await this.chatService.checkOpponentLeft(message['room_id'], sender); + client.send( JSON.stringify({ event: 'send-message', data: { sent: true } }), ); diff --git a/BE/src/common/S3Handler.ts b/BE/src/common/S3Handler.ts deleted file mode 100644 index 9cc0bf2..0000000 --- a/BE/src/common/S3Handler.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import { - DeleteObjectCommand, - PutObjectCommand, - S3Client, -} from '@aws-sdk/client-s3'; -import { uuid } from 'uuidv4'; -import { HttpException, Injectable } from '@nestjs/common'; - -@Injectable() -export class S3Handler { - s3: S3Client; - constructor(private configService: ConfigService) { - this.s3 = new S3Client({ - endpoint: configService.get('S3_ENDPOINT'), - region: configService.get('S3_REGION'), - credentials: { - accessKeyId: configService.get('S3_ACCESS_KEY'), - secretAccessKey: configService.get('S3_SECRET_KEY'), - }, - }); - } - async uploadFile(file: Express.Multer.File) { - const fileName = uuid(); - const command = new PutObjectCommand({ - Bucket: this.configService.get('S3_BUCKET'), - Key: fileName, - ACL: 'public-read', - Body: file.buffer, - }); - try { - await this.s3.send(command); - return `${this.configService.get('S3_ENDPOINT')}/${this.configService.get( - 'S3_BUCKET', - )}/${fileName}`; - } catch (e) { - throw new HttpException('업로드에 실패하였습니다.', 500); - } - } - async deleteFile(fileLocation: string) { - const fileKey = fileLocation.split('/').pop(); - const command = new DeleteObjectCommand({ - Bucket: this.configService.get('S3_BUCKET'), - Key: fileKey, - }); - try { - await this.s3.send(command); - } catch (e) { - throw new HttpException('이미지 삭제에 실패하였습니다.', 500); - } - } -} diff --git a/BE/src/common/base.repository.ts b/BE/src/common/base.repository.ts new file mode 100644 index 0000000..7d729e6 --- /dev/null +++ b/BE/src/common/base.repository.ts @@ -0,0 +1,15 @@ +import { DataSource, EntityManager, Repository } from 'typeorm'; +import { ENTITY_MANAGER_KEY } from './interceptor/transaction.interceptor'; + +export class BaseRepository { + constructor( + private dataSource: DataSource, + private request: Request, + ) {} + + getRepository(entityCls: new () => T): Repository { + const entityManager: EntityManager = + this.request[ENTITY_MANAGER_KEY] ?? this.dataSource.manager; + return entityManager.getRepository(entityCls); + } +} diff --git a/BE/src/common/greenEyeHandler.ts b/BE/src/common/greenEyeHandler.ts index f1c8bd0..32f3560 100644 --- a/BE/src/common/greenEyeHandler.ts +++ b/BE/src/common/greenEyeHandler.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { uuid } from 'uuidv4'; +import { v4 as uuid } from 'uuid'; import { ConfigService } from '@nestjs/config'; import { Injectable } from '@nestjs/common'; diff --git a/BE/src/common/interceptor/transaction.interceptor.ts b/BE/src/common/interceptor/transaction.interceptor.ts new file mode 100644 index 0000000..5bd2a6e --- /dev/null +++ b/BE/src/common/interceptor/transaction.interceptor.ts @@ -0,0 +1,42 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { concatMap, finalize, Observable } from 'rxjs'; +import { Request } from 'express'; +import { DataSource } from 'typeorm'; +import { catchError } from 'rxjs/operators'; + +export const ENTITY_MANAGER_KEY = 'ENTITY_MANAGER'; + +@Injectable() +export class TransactionInterceptor implements NestInterceptor { + constructor(private dataSource: DataSource) {} + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const req = context.switchToHttp().getRequest(); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + req[ENTITY_MANAGER_KEY] = queryRunner.manager; + + return next.handle().pipe( + concatMap(async (data) => { + await queryRunner.commitTransaction(); + return data; + }), + catchError(async (e) => { + await queryRunner.rollbackTransaction(); + throw e; + }), + finalize(async () => { + await queryRunner.release(); + }), + ); + } +} diff --git a/BE/src/config/mysql.config.ts b/BE/src/config/mysql.config.ts index f54f52b..62c6eae 100644 --- a/BE/src/config/mysql.config.ts +++ b/BE/src/config/mysql.config.ts @@ -6,9 +6,9 @@ import { PostEntity } from '../entities/post.entity'; import { BlockUserEntity } from '../entities/blockUser.entity'; import { PostImageEntity } from '../entities/postImage.entity'; import { BlockPostEntity } from '../entities/blockPost.entity'; -import { ChatRoomEntity } from 'src/entities/chatRoom.entity'; +import { ChatRoomEntity } from '../entities/chatRoom.entity'; import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; -import { ChatEntity } from 'src/entities/chat.entity'; +import { ChatEntity } from '../entities/chat.entity'; import { ReportEntity } from '../entities/report.entity'; @Injectable() diff --git a/BE/src/config/s3.config.ts b/BE/src/config/s3.config.ts new file mode 100644 index 0000000..d06dc02 --- /dev/null +++ b/BE/src/config/s3.config.ts @@ -0,0 +1,20 @@ +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { S3Client } from '@aws-sdk/client-s3'; + +export const S3Provider = [ + { + provide: 'S3_CLIENT', + import: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + return new S3Client({ + endpoint: configService.get('S3_ENDPOINT'), + region: configService.get('S3_REGION'), + credentials: { + accessKeyId: configService.get('S3_ACCESS_KEY'), + secretAccessKey: configService.get('S3_SECRET_KEY'), + }, + }); + }, + }, +]; diff --git a/BE/src/entities/blockUser.entity.ts b/BE/src/entities/blockUser.entity.ts index f87e181..9b7e11c 100644 --- a/BE/src/entities/blockUser.entity.ts +++ b/BE/src/entities/blockUser.entity.ts @@ -6,6 +6,7 @@ import { DeleteDateColumn, } from 'typeorm'; import { UserEntity } from './user.entity'; +import { PostEntity } from './post.entity'; @Entity('block_user') export class BlockUserEntity { @@ -19,10 +20,14 @@ export class BlockUserEntity { delete_date: Date; @ManyToOne(() => UserEntity, (blocker) => blocker.user_hash) - @JoinColumn({ name: 'blocker' }) + @JoinColumn({ name: 'blocker', referencedColumnName: 'user_hash' }) blockerUser: UserEntity; @ManyToOne(() => UserEntity, (blocked) => blocked.user_hash) @JoinColumn({ name: 'blocked_user', referencedColumnName: 'user_hash' }) blockedUser: UserEntity; + + @ManyToOne(() => PostEntity, (blocked) => blocked.user_hash) + @JoinColumn({ name: 'blocked_user', referencedColumnName: 'user_hash' }) + blockedUserPost: PostEntity; } diff --git a/BE/src/entities/chatRoom.entity.ts b/BE/src/entities/chatRoom.entity.ts index 65683c8..3ad534f 100644 --- a/BE/src/entities/chatRoom.entity.ts +++ b/BE/src/entities/chatRoom.entity.ts @@ -8,6 +8,7 @@ import { ManyToOne, OneToMany, PrimaryGeneratedColumn, + OneToOne, } from 'typeorm'; import { PostEntity } from './post.entity'; import { ChatEntity } from './chat.entity'; @@ -42,9 +43,28 @@ export class ChatRoomEntity { @DeleteDateColumn() delete_date: Date; + @Column() + last_chat_id: number; + + @Column({ default: false }) + writer_hide: boolean; + + @Column({ default: false }) + user_hide: boolean; + + @Column({ default: null, type: 'timestamp' }) + writer_left_time: Date; + + @Column({ default: null, type: 'timestamp' }) + user_left_time: Date; + @OneToMany(() => ChatEntity, (chat) => chat.chatRoom) chats: ChatEntity[]; + @OneToOne(() => ChatEntity, (chat) => chat.id) + @JoinColumn({ name: 'last_chat_id' }) + lastChat: ChatEntity; + @ManyToOne(() => PostEntity, (post) => post.id) @JoinColumn({ name: 'post_id' }) post: PostEntity; diff --git a/BE/src/entities/post.entity.ts b/BE/src/entities/post.entity.ts index ec003b2..6b949fd 100644 --- a/BE/src/entities/post.entity.ts +++ b/BE/src/entities/post.entity.ts @@ -13,6 +13,7 @@ import { UserEntity } from './user.entity'; import { PostImageEntity } from './postImage.entity'; import { BlockPostEntity } from './blockPost.entity'; import { ReportEntity } from './report.entity'; +import { BlockUserEntity } from './blockUser.entity'; @Entity('post') export class PostEntity { @@ -62,9 +63,16 @@ export class PostEntity { @Column({ length: 2048, nullable: true, charset: 'utf8' }) thumbnail: string; - @OneToMany(() => PostImageEntity, (post_image) => post_image.post) + @OneToMany(() => PostImageEntity, (post_image) => post_image.post, { + cascade: ['soft-remove'], + }) post_images: PostImageEntity[]; - @OneToMany(() => BlockPostEntity, (post_image) => post_image.blocked_post) + @OneToMany(() => BlockPostEntity, (block_post) => block_post.blockedPost, { + cascade: ['soft-remove'], + }) blocked_posts: BlockPostEntity[]; + + @OneToMany(() => BlockUserEntity, (block_user) => block_user.blockedUserPost) + blocked_users: BlockUserEntity[]; } diff --git a/BE/src/entities/user.entity.ts b/BE/src/entities/user.entity.ts index b8598f8..fb6776f 100644 --- a/BE/src/entities/user.entity.ts +++ b/BE/src/entities/user.entity.ts @@ -21,13 +21,17 @@ export class UserEntity { @OneToMany(() => PostEntity, (post) => post.user) posts: PostEntity[]; - @OneToMany(() => BlockUserEntity, (blockUser) => blockUser.blocker) + @OneToMany(() => BlockUserEntity, (blockUser) => blockUser.blockerUser, { + cascade: ['soft-remove'], + }) blocker: BlockUserEntity[]; @OneToMany(() => BlockUserEntity, (blockUser) => blockUser.blocked_user) blocked: BlockUserEntity[]; - @OneToMany(() => BlockPostEntity, (blockUser) => blockUser.blocker) + @OneToMany(() => BlockPostEntity, (blockUser) => blockUser.blockerUser, { + cascade: ['soft-remove'], + }) blocker_post: BlockPostEntity[]; @OneToOne( diff --git a/BE/src/image/image.module.ts b/BE/src/image/image.module.ts new file mode 100644 index 0000000..1ec7df5 --- /dev/null +++ b/BE/src/image/image.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ImageService } from './image.service'; +import { S3Provider } from '../config/s3.config'; +import { PostImageRepository } from './postImage.repository'; + +@Module({ + providers: [ImageService, ...S3Provider, PostImageRepository], + exports: [ImageService], +}) +export class ImageModule {} diff --git a/BE/src/image/image.service.spec.ts b/BE/src/image/image.service.spec.ts new file mode 100644 index 0000000..0f56c77 --- /dev/null +++ b/BE/src/image/image.service.spec.ts @@ -0,0 +1,214 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ImageService } from './image.service'; +import { PostImageRepository } from './postImage.repository'; +import { ConfigService } from '@nestjs/config'; +import { HttpException } from '@nestjs/common'; +import { PostImageEntity } from '../entities/postImage.entity'; +jest.mock('uuid', () => ({ + v4: jest.fn(() => 'fixed-uuid-value'), +})); + +const mockRepository = { + save: jest.fn(), + softDelete: jest.fn(), + findOne: jest.fn(), +}; + +const mockPostImageRepository = { + getRepository: jest.fn().mockReturnValue(mockRepository), +}; + +describe('ImageService', () => { + let service: ImageService; + let postImageRepository; + let s3ClientMock; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ImageService, + { + provide: PostImageRepository, + useValue: mockPostImageRepository, + }, + { + provide: ConfigService, + useValue: { get: jest.fn((key: string) => 'mocked-value') }, + }, + { + provide: 'S3_CLIENT', + useValue: { send: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(ImageService); + postImageRepository = module.get(PostImageRepository); + s3ClientMock = module.get('S3_CLIENT'); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('uploadImage', function () { + const file: Express.Multer.File = { + buffer: Buffer.from('test-content'), + originalname: 'test.jpg', + mimetype: 'image/jpeg', + size: 1024, + fieldname: 'image', + encoding: '7bit', + destination: '', + filename: 'test.jpg', + path: '', + stream: {} as any, + }; + it('should upload file', async function () { + s3ClientMock.send.mockReturnValue(undefined); + const res = await service.uploadImage(file); + expect(res).toEqual('mocked-value/mocked-value/fixed-uuid-value'); + }); + + it('should fail to upload', function () { + s3ClientMock.send.mockRejectedValue(new Error('test')); + expect(async () => { + await service.uploadImage(file); + }).rejects.toThrowError( + new HttpException('업로드에 실패하였습니다.', 500), + ); + }); + }); + + describe('createPostImages', function () { + const files = []; + for (let i = 0; i < 7; i++) { + const file: Express.Multer.File = { + buffer: Buffer.from(`test-content ${i}`), + originalname: 'test.jpg', + mimetype: 'image/jpeg', + size: 1024, + fieldname: 'image', + encoding: '7bit', + destination: '', + filename: 'test.jpg', + path: '', + stream: {} as any, + }; + files.push(file); + } + + it('should create images', async function () { + s3ClientMock.send.mockReturnValue(undefined); + postImageRepository.getRepository().save.mockResolvedValue('test'); + const res = await service.createPostImages(files, 3); + expect(res).toEqual('mocked-value/mocked-value/fixed-uuid-value'); + expect(s3ClientMock.send).toHaveBeenCalledTimes(7); + expect(postImageRepository.getRepository().save).toHaveBeenCalledTimes(1); + }); + + it('should fail to upload images', async function () { + s3ClientMock.send.mockRejectedValue(new Error('fail to upload image')); + await expect(async () => { + await service.createPostImages(files, 3); + }).rejects.toThrowError( + new HttpException('업로드에 실패하였습니다.', 500), + ); + }); + + it('should fail to create images', async function () { + postImageRepository.getRepository().save.mockResolvedValue('test'); + postImageRepository + .getRepository() + .save.mockRejectedValue(new Error('fail to create images')); + await expect(async () => { + await service.createPostImages(files, 3); + }).rejects.toThrowError(); + expect(s3ClientMock.send).toHaveBeenCalledTimes(7); + }); + }); + + describe('removePostImages', function () { + const imageLocations = ['test1', 'test2', 'test3']; + it('should remove post images', async function () { + await service.removePostImages(imageLocations); + expect( + postImageRepository.getRepository().softDelete, + ).toHaveBeenCalledTimes(1); + }); + + it('should fail to remove images', async function () { + postImageRepository + .getRepository() + .softDelete.mockRejectedValue(new Error('fail to remove images')); + await expect(async () => { + await service.removePostImages(imageLocations); + }).rejects.toThrowError(); + }); + }); + + describe('', function () { + const files = []; + for (let i = 0; i < 2; i++) { + const file: Express.Multer.File = { + buffer: Buffer.from(`test-content ${i}`), + originalname: 'test.jpg', + mimetype: 'image/jpeg', + size: 1024, + fieldname: 'image', + encoding: '7bit', + destination: '', + filename: 'test.jpg', + path: '', + stream: {} as any, + }; + files.push(file); + } + it('should update Post Image', async function () { + const postImageEntity: PostImageEntity = { + id: 1, + post_id: 1, + image_url: 'updatedImageUrl', + delete_date: null, + post: null, + }; + postImageRepository + .getRepository() + .findOne.mockResolvedValue(postImageEntity); + jest.spyOn(service, 'createPostImages').mockResolvedValue(undefined); + jest.spyOn(service, 'removePostImages').mockResolvedValue(undefined); + const deletedImages = ['image1', 'image2']; + const postId = 1; + + const result = await service.updatePostImage( + files, + deletedImages, + postId, + ); + + expect(service.createPostImages).toHaveBeenCalledWith(files, postId); + expect(service.removePostImages).toHaveBeenCalledWith(deletedImages); + expect(postImageRepository.getRepository().findOne).toHaveBeenCalledWith({ + where: { post_id: postId }, + order: { id: 'ASC' }, + }); + expect(result).toBe('updatedImageUrl'); + }); + + it('should return null', async function () { + jest.spyOn(service, 'createPostImages').mockResolvedValue(undefined); + jest.spyOn(service, 'removePostImages').mockResolvedValue(undefined); + postImageRepository.getRepository().findOne.mockResolvedValue(null); + const deletedImages = ['image1', 'image2']; // Replace with your actual deleted image data + const postId = 1; + + const result = await service.updatePostImage( + files, + deletedImages, + postId, + ); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/BE/src/image/image.service.ts b/BE/src/image/image.service.ts new file mode 100644 index 0000000..3c68014 --- /dev/null +++ b/BE/src/image/image.service.ts @@ -0,0 +1,92 @@ +import { HttpException, Inject, Injectable } from '@nestjs/common'; +import { + DeleteObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { v4 as uuid } from 'uuid'; +import { ConfigService } from '@nestjs/config'; +import { PostImageEntity } from '../entities/postImage.entity'; +import { PostImageRepository } from './postImage.repository'; +import { In } from 'typeorm'; + +@Injectable() +export class ImageService { + constructor( + @Inject('S3_CLIENT') + private readonly s3Client: S3Client, + private postImageRepository: PostImageRepository, + private configService: ConfigService, + ) {} + async uploadImage(file: Express.Multer.File) { + const fileName = uuid(); + const command = new PutObjectCommand({ + Bucket: this.configService.get('S3_BUCKET'), + Key: fileName, + ACL: 'public-read', + Body: file.buffer, + }); + try { + await this.s3Client.send(command); + return `${this.configService.get('S3_ENDPOINT')}/${this.configService.get( + 'S3_BUCKET', + )}/${fileName}`; + } catch (e) { + throw new HttpException('업로드에 실패하였습니다.', 500); + } + } + + async createPostImages( + files: Express.Multer.File[], + postId: number, + ): Promise { + const postImageEntities = []; + for (const file of files) { + const imageLocation = await this.uploadImage(file); + const postImageEntity = new PostImageEntity(); + postImageEntity.image_url = imageLocation; + postImageEntity.post_id = postId; + postImageEntities.push(postImageEntity); + } + await this.postImageRepository + .getRepository(PostImageEntity) + .save(postImageEntities); + return postImageEntities[0].image_url; + } + + async deleteImage(fileLocation: string) { + const fileKey = fileLocation.split('/').pop(); + const command = new DeleteObjectCommand({ + Bucket: this.configService.get('S3_BUCKET'), + Key: fileKey, + }); + try { + await this.s3Client.send(command); + } catch (e) { + throw new HttpException('이미지 삭제에 실패하였습니다.', 500); + } + } + + async removePostImages(deletedImages: string[]) { + await this.postImageRepository + .getRepository(PostImageEntity) + .softDelete({ image_url: In(deletedImages) }); + } + + async updatePostImage( + files: Array, + deletedImages: string[], + postId: number, + ): Promise { + if (files.length > 0) { + await this.createPostImages(files, postId); + } + if (deletedImages) { + await this.removePostImages(deletedImages); + } + const postImageEntity = await this.postImageRepository + .getRepository(PostImageEntity) + .findOne({ where: { post_id: postId }, order: { id: 'ASC' } }); + return postImageEntity === null ? null : postImageEntity.image_url; + } +} diff --git a/BE/src/image/postImage.repository.ts b/BE/src/image/postImage.repository.ts new file mode 100644 index 0000000..41b6ba5 --- /dev/null +++ b/BE/src/image/postImage.repository.ts @@ -0,0 +1,11 @@ +import { BaseRepository } from '../common/base.repository'; +import { DataSource } from 'typeorm'; +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; + +@Injectable({ scope: Scope.REQUEST }) +export class PostImageRepository extends BaseRepository { + constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { + super(dataSource, req); + } +} diff --git a/BE/src/notification/notification.module.ts b/BE/src/notification/notification.module.ts new file mode 100644 index 0000000..628360e --- /dev/null +++ b/BE/src/notification/notification.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { NotificationService } from './notification.service'; +import { RegistrationTokenRepository } from './registrationToken.repository'; + +@Module({ + exports: [NotificationService], + providers: [NotificationService, RegistrationTokenRepository], +}) +export class NotificationModule {} diff --git a/BE/src/notification/notification.service.spec.ts b/BE/src/notification/notification.service.spec.ts new file mode 100644 index 0000000..e7b0dd8 --- /dev/null +++ b/BE/src/notification/notification.service.spec.ts @@ -0,0 +1,102 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationService } from './notification.service'; +import { ConfigService } from '@nestjs/config'; +import { RegistrationTokenRepository } from './registrationToken.repository'; +import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; +import { PushMessage } from '../common/fcmHandler'; + +const mockRepository = { + save: jest.fn(), + delete: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), +}; + +const mockAdmin = jest.requireMock('firebase-admin'); +jest.mock('firebase-admin'); +mockAdmin.apps = []; + +describe('NotificationService', () => { + let service: NotificationService; + let repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationService, + { + provide: RegistrationTokenRepository, + useValue: mockRepository, + }, + { + provide: ConfigService, + useValue: { get: jest.fn((key: string) => 'mocked-value') }, + }, + ], + }).compile(); + + service = module.get(NotificationService); + repository = module.get(RegistrationTokenRepository); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getRegistrationToken', function () { + it('should return null when token does not exist', async function () { + repository.findOne.mockResolvedValue(null); + const res = await service.getRegistrationToken('user'); + expect(res).toEqual(null); + }); + + it('should return registration token', async function () { + const registrationToken = new RegistrationTokenEntity(); + registrationToken.registration_token = 'test'; + repository.findOne.mockResolvedValue(registrationToken); + const res = await service.getRegistrationToken('user'); + expect(res).toEqual('test'); + }); + }); + + describe('registerToken', function () { + it('should create token', async function () { + repository.findOne.mockResolvedValue(null); + await service.registerToken('user', 'token'); + expect(repository.save).toHaveBeenCalled(); + }); + + it('should update token', async function () { + repository.findOne.mockResolvedValue(new RegistrationTokenEntity()); + await service.registerToken('user', 'token'); + expect(repository.update).toHaveBeenCalled(); + }); + }); + + describe('removeRegistrationToken', function () { + it('should remove', async function () { + await service.removeRegistrationToken('userId'); + expect(repository.delete).toHaveBeenCalled(); + }); + }); + + describe('createChatNotificationMessage', function () { + it('should return message', function () { + const pushMessage: PushMessage = { + body: 'message', + data: { + room_id: '123', + }, + title: 'nickname', + }; + const result = service.createChatNotificationMessage( + 'token', + pushMessage, + ); + expect(result.token).toEqual('token'); + expect(result.notification.title).toEqual('nickname'); + expect(result.notification.body).toEqual('message'); + expect(result.data.room_id).toEqual('123'); + }); + }); +}); diff --git a/BE/src/notification/notification.service.ts b/BE/src/notification/notification.service.ts new file mode 100644 index 0000000..992abc9 --- /dev/null +++ b/BE/src/notification/notification.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import admin from 'firebase-admin'; +import { PushMessage } from '../common/fcmHandler'; +import { RegistrationTokenRepository } from './registrationToken.repository'; + +@Injectable() +export class NotificationService { + private readonly logger = new Logger('ChatsGateway'); + constructor( + private configService: ConfigService, + private registrationTokenRepository: RegistrationTokenRepository, + ) { + if (admin.apps.length === 0) { + admin.initializeApp({ + credential: admin.credential.cert( + this.configService.get('GOOGLE_APPLICATION_CREDENTIALS'), + ), + }); + this.logger.log('Firebase Admin initialized'); + } + } + + async sendChatNotification(userId: string, pushMessage: PushMessage) { + const registrationToken = await this.getRegistrationToken(userId); + if (!registrationToken) { + throw new Error('no registration token'); + } + const message = this.createChatNotificationMessage( + registrationToken, + pushMessage, + ); + try { + const response = await admin.messaging().send(message); + this.logger.debug( + `Push Notification Success : ${response} `, + 'FcmHandler', + ); + } catch (e) { + throw new Error('fail to send chat notification'); + } + } + + createChatNotificationMessage( + registrationToken: string, + pushMessage: PushMessage, + ) { + return { + token: registrationToken, + notification: { + title: pushMessage.title, + body: pushMessage.body, + }, + apns: { + payload: { + aps: { + sound: 'default', + }, + }, + }, + data: { + ...pushMessage.data, + }, + }; + } + + async getRegistrationToken(userId: string): Promise { + const registrationToken = + await this.registrationTokenRepository.findOne(userId); + if (registrationToken === null) { + this.logger.error('토큰이 없습니다.', 'FcmHandler'); + return null; + } + return registrationToken.registration_token; + } + + async registerToken(userId, registrationToken) { + const registrationTokenEntity = + await this.registrationTokenRepository.findOne(userId); + if (registrationTokenEntity === null) { + await this.registrationTokenRepository.save(userId, registrationToken); + } else { + await this.registrationTokenRepository.update(userId, registrationToken); + } + } + + async removeRegistrationToken(userId: string) { + await this.registrationTokenRepository.delete(userId); + } +} diff --git a/BE/src/notification/registrationToken.repository.ts b/BE/src/notification/registrationToken.repository.ts new file mode 100644 index 0000000..69c6c76 --- /dev/null +++ b/BE/src/notification/registrationToken.repository.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { BaseRepository } from '../common/base.repository'; +import { DataSource } from 'typeorm'; +import { REQUEST } from '@nestjs/core'; +import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; + +@Injectable({ scope: Scope.REQUEST }) +export class RegistrationTokenRepository extends BaseRepository { + constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { + super(dataSource, req); + } + async findOne(userId: string): Promise { + return await this.getRepository(RegistrationTokenEntity).findOne({ + where: { user_hash: userId }, + }); + } + + async save(userId: string, registrationToken: string) { + await this.getRepository(RegistrationTokenEntity).save({ + user_hash: userId, + registration_token: registrationToken, + }); + } + + async update(userId: string, registrationToken: string) { + await this.getRepository(RegistrationTokenEntity).update( + { + user_hash: userId, + }, + { registration_token: registrationToken }, + ); + } + + async delete(userId: string) { + await this.getRepository(RegistrationTokenEntity).delete({ + user_hash: userId, + }); + } +} diff --git a/BE/src/post/dto/postList.dto.ts b/BE/src/post/dto/postList.dto.ts index 46ee25f..5281548 100644 --- a/BE/src/post/dto/postList.dto.ts +++ b/BE/src/post/dto/postList.dto.ts @@ -5,7 +5,7 @@ export class PostListDto { @IsNumber() @IsOptional() @Type(() => Number) - page: number; + cursorId: number; @IsNumber() @IsOptional() diff --git a/BE/src/post/post.controller.ts b/BE/src/post/post.controller.ts index 72fe372..f255629 100644 --- a/BE/src/post/post.controller.ts +++ b/BE/src/post/post.controller.ts @@ -23,12 +23,17 @@ import { PostListDto } from './dto/postList.dto'; import { AuthGuard } from 'src/common/guard/auth.guard'; import { UserHash } from 'src/common/decorator/auth.decorator'; import { FilesSizeValidator } from '../common/files.validator'; +import { TransactionInterceptor } from '../common/interceptor/transaction.interceptor'; +import { ImageService } from '../image/image.service'; @Controller('posts') @ApiTags('posts') @UseGuards(AuthGuard) export class PostController { - constructor(private readonly postService: PostService) {} + constructor( + private readonly postService: PostService, + private readonly imageService: ImageService, + ) {} @Get() async postsList(@Query() query: PostListDto, @UserHash() userId: string) { @@ -42,6 +47,7 @@ export class PostController { @Post() @UseInterceptors(FilesInterceptor('image', 12)) + @UseInterceptors(TransactionInterceptor) async postsCreate( @UploadedFiles(new FilesSizeValidator()) files: Array, @@ -52,11 +58,11 @@ export class PostController { body: PostCreateDto, @UserHash() userId: string, ) { - let imageLocation: Array = []; - if (body.is_request === false && files !== undefined) { - imageLocation = await this.postService.uploadImages(files); + const postId = await this.postService.createPost(body, userId); + if (body.is_request === false && files.length !== 0) { + const thumbnail = await this.imageService.createPostImages(files, postId); + await this.postService.updatePostThumbnail(thumbnail, postId); } - await this.postService.createPost(imageLocation, body, userId); } @Get('/:id') @@ -68,8 +74,9 @@ export class PostController { @Patch('/:id') @ApiOperation({ summary: 'fix post context', description: '게시글 수정' }) @UseInterceptors(FilesInterceptor('image', 12)) + @UseInterceptors(TransactionInterceptor) async postModify( - @Param('id') id: number, + @Param('id') postId: number, @UploadedFiles(new FilesSizeValidator()) files: Array, @MultiPartBody( @@ -79,21 +86,17 @@ export class PostController { body: UpdatePostDto, @UserHash() userId, ) { - const isFixed = await this.postService.updatePostById( - id, - body, + await this.postService.checkAuth(postId, userId); + const updatedThumbnail = await this.imageService.updatePostImage( files, - userId, + body.deleted_images, + postId, ); - - if (isFixed) { - return HttpCode(200); - } else { - throw new HttpException('서버 오류입니다.', 500); - } + await this.postService.updatePostById(postId, body, updatedThumbnail); } @Delete('/:id') + @UseInterceptors(TransactionInterceptor) async postRemove(@Param('id') id: number, @UserHash() userId) { await this.postService.removePost(id, userId); } diff --git a/BE/src/post/post.module.ts b/BE/src/post/post.module.ts index 7c2ed45..f910ac4 100644 --- a/BE/src/post/post.module.ts +++ b/BE/src/post/post.module.ts @@ -3,11 +3,12 @@ import { PostController } from './post.controller'; import { PostService } from './post.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PostEntity } from '../entities/post.entity'; -import { S3Handler } from '../common/S3Handler'; import { PostImageEntity } from '../entities/postImage.entity'; import { BlockUserEntity } from '../entities/blockUser.entity'; import { BlockPostEntity } from '../entities/blockPost.entity'; import { AuthGuard } from 'src/common/guard/auth.guard'; +import { PostRepository } from './post.repository'; +import { ImageModule } from '../image/image.module'; @Module({ imports: [ @@ -17,8 +18,9 @@ import { AuthGuard } from 'src/common/guard/auth.guard'; BlockUserEntity, BlockPostEntity, ]), + ImageModule, ], controllers: [PostController], - providers: [PostService, S3Handler, AuthGuard], + providers: [PostService, AuthGuard, PostRepository], }) export class PostModule {} diff --git a/BE/src/post/post.repository.spec.ts b/BE/src/post/post.repository.spec.ts new file mode 100644 index 0000000..0ee914a --- /dev/null +++ b/BE/src/post/post.repository.spec.ts @@ -0,0 +1,67 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PostRepository } from './post.repository'; +import { DataSource } from 'typeorm'; +import { REQUEST } from '@nestjs/core'; +import { Request } from 'express'; + +const createMockRequest = (overrides: Partial = {}): Request => { + return { ...overrides } as Request; +}; + +const mockDataSource = { + createEntityManager: jest.fn(), +}; + +const mockRequest = createMockRequest({ + method: 'POST', + url: '/api', + headers: { 'Content-Type': 'application/json' }, +}); + +describe('', () => { + let repository: PostRepository; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PostRepository, + { provide: DataSource, useValue: mockDataSource }, + { provide: REQUEST, useValue: mockRequest }, + ], + }).compile(); + repository = await module.resolve(PostRepository); + }); + + it('should be defined', () => { + expect(repository).toBeDefined(); + }); + + describe('createPost()', () => { + it('should success (nothing)', async function () { + const res = repository.createOption({ + cursorId: undefined, + requestFilter: undefined, + writer: undefined, + searchKeyword: undefined, + }); + expect(res).toBe('post.id > -1'); + }); + it('should success (page)', async function () { + const res = repository.createOption({ + cursorId: 1, + requestFilter: undefined, + writer: undefined, + searchKeyword: undefined, + }); + expect(res).toBe('post.id < 1'); + }); + it('should success (more than two options)', async function () { + const res = repository.createOption({ + cursorId: 1, + requestFilter: undefined, + writer: 'user', + searchKeyword: undefined, + }); + expect(res).toBe("post.id < 1 AND post.user_hash = 'user'"); + }); + }); +}); diff --git a/BE/src/post/post.repository.ts b/BE/src/post/post.repository.ts new file mode 100644 index 0000000..e3aef79 --- /dev/null +++ b/BE/src/post/post.repository.ts @@ -0,0 +1,83 @@ +import { DataSource } from 'typeorm'; +import { PostEntity } from '../entities/post.entity'; +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { PostListDto } from './dto/postList.dto'; +import { BaseRepository } from '../common/base.repository'; +import { REQUEST } from '@nestjs/core'; + +@Injectable({ scope: Scope.REQUEST }) +export class PostRepository extends BaseRepository { + constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { + super(dataSource, req); + } + + async findExceptBlock( + blocker: string, + options: PostListDto, + ): Promise> { + const limit = 20; + return await this.getRepository(PostEntity) + .createQueryBuilder('post') + .leftJoin( + 'post.blocked_posts', + 'bp', + 'bp.blocker = :blocker AND bp.blocked_post = post.id', + { blocker: blocker }, + ) + .leftJoin( + 'post.blocked_users', + 'bu', + 'bu.blocker = :blocker AND bu.blocked_user = post.user_hash', + ) + .where('bp.blocked_post IS NULL') + .andWhere('bu.blocked_user IS NULL') + .andWhere(this.createOption(options)) + .orderBy('post.id', 'DESC') + .limit(limit) + .getMany(); + } + + async findOneWithBlock(blocker: string, postId: number) { + return await this.getRepository(PostEntity) + .createQueryBuilder('post') + .leftJoinAndSelect( + 'post.blocked_posts', + 'bp', + 'bp.blocker = :blocker AND bp.blocked_post = post.id', + { blocker: blocker }, + ) + .leftJoinAndSelect( + 'post.blocked_users', + 'bu', + 'bu.blocker = :blocker AND bu.blocked_user = post.user_hash', + ) + .leftJoinAndSelect('post.post_images', 'pi', 'pi.post_id = post.id') + .where('post.id = :postId', { postId: postId }) + .getOne(); + } + + async softDeleteCascade(postId: number) { + const post = await this.getRepository(PostEntity).findOne({ + where: { id: postId }, + relations: ['blocked_posts', 'post_images'], + }); + await this.getRepository(PostEntity).softRemove(post); + } + + createOption(options: PostListDto) { + let option = + options.cursorId === undefined + ? 'post.id > -1 AND ' + : `post.id < ${options.cursorId} AND `; + if (options.requestFilter !== undefined) { + option += `post.is_request = ${options.requestFilter} AND `; + } + if (options.writer !== undefined) { + option += `post.user_hash = '${options.writer}' AND `; + } + if (options.searchKeyword !== undefined) { + option += `post.title LIKE '%${options.searchKeyword}%' AND `; + } + return option.replace(/\s*AND\s*$/, '').trim(); + } +} diff --git a/BE/src/post/post.service.spec.ts b/BE/src/post/post.service.spec.ts new file mode 100644 index 0000000..528c5f6 --- /dev/null +++ b/BE/src/post/post.service.spec.ts @@ -0,0 +1,201 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PostService } from './post.service'; +import { PostRepository } from './post.repository'; +import { PostListDto } from './dto/postList.dto'; +import { PostEntity } from '../entities/post.entity'; +import { PostImageEntity } from '../entities/postImage.entity'; +import { HttpException } from '@nestjs/common'; +import { BlockPostEntity } from '../entities/blockPost.entity'; +import { UpdatePostDto } from './dto/postUpdate.dto'; +import { PostCreateDto } from './dto/postCreate.dto'; + +const mockRepository = { + save: jest.fn(), + softDelete: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + update: jest.fn(), +}; + +const mockPostRepository = { + getRepository: jest.fn().mockReturnValue(mockRepository), + findExceptBlock: jest.fn(), + findOneWithBlock: jest.fn(), + softDeleteCascade: jest.fn(), +}; +describe('PostService', function () { + let service: PostService; + let postRepository; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PostService, + { + provide: PostRepository, + useValue: mockPostRepository, + }, + ], + }).compile(); + + service = module.get(PostService); + postRepository = module.get(PostRepository); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findPosts', function () { + const postEntities: PostEntity[] = []; + for (let i = 0; i < 20; i++) { + const postEntity = new PostEntity(); + postEntity.title = 'title' + i; + postEntity.price = 10000; + postEntity.id = i; + postEntity.user_hash = 'user' + i; + postEntity.is_request = false; + postEntity.thumbnail = 'www.test.com' + i; + postEntity.start_date = new Date(); + postEntity.end_date = new Date(); + postEntities.push(postEntity); + } + it('should return posts', async function () { + postRepository.findExceptBlock.mockResolvedValue(postEntities); + const res = await service.findPosts(new PostListDto(), 'user'); + expect(res.length).toEqual(20); + }); + }); + + describe('findPostById', function () { + const postEntity = new PostEntity(); + postEntity.title = 'title'; + postEntity.price = 10000; + postEntity.id = 1; + postEntity.user_hash = 'user'; + postEntity.is_request = false; + postEntity.thumbnail = 'www.test.com'; + postEntity.start_date = new Date(); + postEntity.end_date = new Date(); + postEntity.blocked_posts = [new BlockPostEntity()]; + postEntity.blocked_users = []; + postEntity.post_images = [new PostImageEntity()]; + + it('should throw 404', async function () { + postRepository.findOneWithBlock.mockResolvedValue(null); + await expect(async () => { + await service.findPostById(1, 'user'); + }).rejects.toThrowError(new HttpException('없는 게시물입니다.', 404)); + }); + + it('should throw 400', async function () { + postRepository.findOneWithBlock.mockResolvedValue(postEntity); + await expect(async () => { + await service.findPostById(1, 'user'); + }).rejects.toThrowError(new HttpException('차단한 게시물입니다.', 400)); + }); + + it('should return post', async function () { + postEntity.blocked_posts.pop(); + postRepository.findOneWithBlock.mockResolvedValue(postEntity); + const res = await service.findPostById(1, 'user'); + expect(res.title).toEqual('title'); + }); + }); + + describe('checkAuth', function () { + const post = new PostEntity(); + post.user_hash = 'user1'; + it('should throw 404', function () { + postRepository.getRepository().findOne.mockResolvedValue(null); + expect(async () => { + await service.checkAuth(1, 'user'); + }).rejects.toThrowError(new HttpException('게시글이 없습니다.', 404)); + }); + + it('should throw 403', function () { + postRepository.getRepository().findOne.mockResolvedValue(post); + expect(async () => { + await service.checkAuth(1, 'user'); + }).rejects.toThrowError(new HttpException('수정 권한이 없습니다.', 403)); + }); + + it('should pass checkAuth', async function () { + postRepository.getRepository().findOne.mockResolvedValue(post); + await service.checkAuth(1, 'user1'); + }); + }); + + describe('findPostsTitles', function () { + it('should return empty array', async function () { + postRepository.getRepository().find.mockResolvedValue([]); + const res = await service.findPostsTitles('test'); + expect(res.length).toEqual(0); + }); + + it('should return 5 title array', async function () { + const posts = []; + for (let i = 0; i < 5; i++) { + const post = new PostEntity(); + post.title = 'test' + i; + posts.push(post); + } + postRepository.getRepository().find.mockResolvedValue(posts); + const res = await service.findPostsTitles('test'); + expect(res.length).toEqual(5); + }); + }); + + describe('updatePostById', function () { + it('should update post by id', async () => { + const postId = 1; + const thumbnail = 'testthumbnail'; + const updatePostDto = new UpdatePostDto(); + // postRepository.getRepository().updat; + await service.updatePostById(postId, updatePostDto, thumbnail); + + expect(mockPostRepository.getRepository).toHaveBeenCalledWith(PostEntity); + expect(mockPostRepository.getRepository().update).toHaveBeenCalledWith( + { id: postId }, + { ...updatePostDto, thumbnail: thumbnail }, + ); + }); + }); + + describe('createPost', function () { + it('should create post', async function () { + const createPostDto = new PostCreateDto(); + createPostDto.title = 'title'; + createPostDto.description = 'test'; + createPostDto.price = null; + createPostDto.is_request = true; + createPostDto.start_date = 'yyyy-mm-dd'; + createPostDto.end_date = 'yyyy-mm-dd'; + const post = new PostEntity(); + post.id = 1; + postRepository.getRepository().save.mockResolvedValue(post); + const res = await service.createPost(createPostDto, 'qwe'); + expect(res).toEqual(1); + }); + }); + + describe('updatePostThumbnail', function () { + it('should update post thumbnail', async function () { + const thumbnail = 'test'; + const postId = 1; + await service.updatePostThumbnail(thumbnail, postId); + expect(mockPostRepository.getRepository().update).toHaveBeenCalledWith( + { id: postId }, + { thumbnail }, + ); + }); + }); + + describe('removePost', function () { + it('should remove post', async function () { + const postId = 1; + jest.spyOn(service, 'checkAuth').mockResolvedValue(undefined); + await service.removePost(postId, 'user'); + expect(mockPostRepository.softDeleteCascade).toHaveBeenCalledWith(postId); + }); + }); +}); diff --git a/BE/src/post/post.service.ts b/BE/src/post/post.service.ts index 43d06b1..8701473 100644 --- a/BE/src/post/post.service.ts +++ b/BE/src/post/post.service.ts @@ -1,113 +1,23 @@ import { HttpException, Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { PostEntity } from '../entities/post.entity'; -import { LessThan, Like, Repository } from 'typeorm'; +import { Like } from 'typeorm'; import { UpdatePostDto } from './dto/postUpdate.dto'; -import { PostImageEntity } from 'src/entities/postImage.entity'; -import { S3Handler } from '../common/S3Handler'; import { PostListDto } from './dto/postList.dto'; -import { BlockUserEntity } from '../entities/blockUser.entity'; -import { BlockPostEntity } from '../entities/blockPost.entity'; -import { FindOperator } from 'typeorm/find-options/FindOperator'; +import { PostRepository } from './post.repository'; -interface WhereOption { - id: FindOperator; - is_request?: boolean; - user_hash?: string; - title?: FindOperator; -} @Injectable() export class PostService { - constructor( - @InjectRepository(PostEntity) - private postRepository: Repository, - @InjectRepository(PostImageEntity) - private postImageRepository: Repository, - @InjectRepository(BlockUserEntity) - private blockUserRepository: Repository, - @InjectRepository(BlockPostEntity) - private blockPostRepository: Repository, - private s3Handler: S3Handler, - ) {} - makeWhereOption(query: PostListDto): WhereOption { - const cursor: FindOperator = - query.page === undefined ? undefined : LessThan(query.page); - const where: WhereOption = { id: cursor }; - if (query.requestFilter !== undefined) { - where.is_request = query.requestFilter !== 0; - } - if (query.writer !== undefined) { - where.user_hash = query.writer; - } - if (query.searchKeyword !== undefined) { - where.title = Like(`%${query.searchKeyword}%`); - } - return where; - } - - async getFilteredList(userId: string) { - const blockedUsersId: string[] = ( - await this.blockUserRepository.find({ - where: { blocker: userId }, - // relations: ['blockedUser'], - // withDeleted: true, - }) - ).map((blockedUser) => blockedUser.blocked_user); - - const blockedPostsId: number[] = ( - await this.blockPostRepository.find({ - where: { blocker: userId }, - }) - ).map((blockedPost) => blockedPost.blocked_post); - return { blockedUsersId, blockedPostsId }; - } - - async filterBlockedPosts( - userId: string, - posts: PostEntity[], - ): Promise { - const { blockedUsersId, blockedPostsId } = - await this.getFilteredList(userId); - return posts.filter((post) => { - return !( - blockedPostsId.includes(post.id) || - blockedUsersId.includes(post.user_hash) - ); - }); - } - - async isFiltered(post: PostEntity, userId: string) { - const { blockedUsersId, blockedPostsId } = - await this.getFilteredList(userId); - return ( - blockedPostsId.includes(post.id) || - blockedUsersId.includes(post.user_hash) - ); - } - + constructor(private postRepository: PostRepository) {} async findPosts(query: PostListDto, userId: string) { - const limit: number = 20; - - const posts = await this.postRepository.find({ - take: limit, - where: this.makeWhereOption(query), - relations: ['post_images', 'user'], - order: { - create_date: 'desc', - }, - }); - const filteredPosts = await this.filterBlockedPosts(userId, posts); - return filteredPosts.map((filteredPost) => { + const posts = await this.postRepository.findExceptBlock(userId, query); + return posts.map((filteredPost) => { return { title: filteredPost.title, price: filteredPost.price, - description: filteredPost.description, post_id: filteredPost.id, user_id: filteredPost.user_hash, is_request: filteredPost.is_request, - images: filteredPost.post_images.map( - (post_image) => post_image.image_url, - ), + post_image: filteredPost.thumbnail, start_date: filteredPost.start_date, end_date: filteredPost.end_date, }; @@ -115,15 +25,12 @@ export class PostService { } async findPostById(postId: number, userId: string) { - const post = await this.postRepository.findOne({ - where: { id: postId }, - relations: ['post_images', 'user'], - }); + const post = await this.postRepository.findOneWithBlock(userId, postId); if (post === null) { throw new HttpException('없는 게시물입니다.', 404); } - if (await this.isFiltered(post, userId)) { + if (post.blocked_posts.length !== 0 || post.blocked_users.length !== 0) { throw new HttpException('차단한 게시물입니다.', 400); } return { @@ -140,14 +47,15 @@ export class PostService { } async checkAuth(postId, userId) { - const isDataExists = await this.postRepository.findOne({ - where: { id: postId }, - relations: ['user'], - }); + const isDataExists = await this.postRepository + .getRepository(PostEntity) + .findOne({ + where: { id: postId }, + }); if (!isDataExists) { throw new HttpException('게시글이 없습니다.', 404); } - if (isDataExists.user.user_hash !== userId) { + if (isDataExists.user_hash !== userId) { throw new HttpException('수정 권한이 없습니다.', 403); } } @@ -155,40 +63,15 @@ export class PostService { async updatePostById( postId: number, updatePostDto: UpdatePostDto, - files: Express.Multer.File[], - userId: string, + thumbnail: string, ) { - await this.checkAuth(postId, userId); - if (files) { - const fileLocation = await this.uploadImages(files); - await this.createImages(fileLocation, postId); - } - - try { - if (updatePostDto.deleted_images !== undefined) { - for (const deleted_image of updatePostDto.deleted_images) { - await this.postImageRepository.softDelete({ - image_url: deleted_image, - }); - } - } - delete updatePostDto.deleted_images; - await this.postRepository.update({ id: postId }, { ...updatePostDto }); - return true; - } catch (e) { - console.log(e); - return null; - } - } - async uploadImages(files: Express.Multer.File[]): Promise { - const fileLocation: Array = []; - for (const file of files) { - fileLocation.push(await this.s3Handler.uploadFile(file)); - } - return fileLocation; + delete updatePostDto.deleted_images; + await this.postRepository + .getRepository(PostEntity) + .update({ id: postId }, { ...updatePostDto, thumbnail }); } - async createPost(imageLocations, createPostDto, userHash) { + async createPost(createPostDto, userHash) { const post = new PostEntity(); post.title = createPostDto.title; @@ -198,40 +81,31 @@ export class PostService { post.start_date = createPostDto.start_date; post.end_date = createPostDto.end_date; post.user_hash = userHash; - post.thumbnail = imageLocations.length > 0 ? imageLocations[0] : null; - const res = await this.postRepository.save(post); - if (res.is_request === false) { - await this.createImages(imageLocations, res.id); - } + post.thumbnail = null; + const res = await this.postRepository.getRepository(PostEntity).save(post); + return res.id; } - async createImages(imageLocations: Array, postId: number) { - for (const imageLocation of imageLocations) { - const postImageEntity = new PostImageEntity(); - postImageEntity.image_url = imageLocation; - postImageEntity.post_id = postId; - await this.postImageRepository.save(postImageEntity); - } + async updatePostThumbnail(thumbnail: string, postId: number) { + await this.postRepository + .getRepository(PostEntity) + .update({ id: postId }, { thumbnail }); } async removePost(postId: number, userId: string) { await this.checkAuth(postId, userId); - await this.deleteCascadingPost(postId); - return true; - } - async deleteCascadingPost(postId: number) { - await this.postImageRepository.softDelete({ post_id: postId }); - await this.blockPostRepository.softDelete({ blocked_post: postId }); - await this.postRepository.softDelete({ id: postId }); + await this.postRepository.softDeleteCascade(postId); } async findPostsTitles(searchKeyword: string) { - const posts: PostEntity[] = await this.postRepository.find({ - where: { title: Like(`%${searchKeyword}%`) }, - order: { - create_date: 'desc', - }, - }); + const posts: PostEntity[] = await this.postRepository + .getRepository(PostEntity) + .find({ + where: { title: Like(`%${searchKeyword}%`) }, + order: { + create_date: 'desc', + }, + }); const titles: string[] = posts.map((post) => post.title); return titles.slice(0, 5); } diff --git a/BE/src/users-block/users-block.controller.ts b/BE/src/users-block/users-block.controller.ts index 911ed5d..0c52fa8 100644 --- a/BE/src/users-block/users-block.controller.ts +++ b/BE/src/users-block/users-block.controller.ts @@ -20,6 +20,11 @@ export class UsersBlockController { return this.usersBlockService.getBlockUser(userId); } + @Get('/:id') + async blockUserCheck(@Param('id') id: string, @UserHash() userId: string) { + return await this.usersBlockService.checkBlockUser(id, userId); + } + @Post('/:id') async blockUserAdd(@Param('id') id: string, @UserHash() userId: string) { await this.usersBlockService.addBlockUser(id, userId); diff --git a/BE/src/users-block/users-block.service.ts b/BE/src/users-block/users-block.service.ts index f588828..e466af9 100644 --- a/BE/src/users-block/users-block.service.ts +++ b/BE/src/users-block/users-block.service.ts @@ -46,6 +46,37 @@ export class UsersBlockService { return await this.blockUserRepository.save(blockUserEntity); } + async checkBlockUser(oppId: string, userId: string) { + const isExistUser = await this.userRepository.findOne({ + where: { user_hash: oppId }, + withDeleted: true, + }); + + if (!isExistUser) { + throw new HttpException('존재하지 않는 유저입니다', 404); + } + + const checkBlock = await this.blockUserRepository.findOne({ + where: { blocker: userId, blocked_user: oppId }, + withDeleted: true, + }); + + if (checkBlock !== null) { + return { block: 'block' }; + } + + const checkBlocked = await this.blockUserRepository.findOne({ + where: { blocker: oppId, blocked_user: userId }, + withDeleted: true, + }); + + if (checkBlocked !== null) { + return { block: 'blocked' }; + } + + return { block: 'none' }; + } + async getBlockUser(id: string) { const res = await this.blockUserRepository.find({ where: { blocker: id }, diff --git a/BE/src/users/dto/usersUpdate.dto.ts b/BE/src/users/dto/usersUpdate.dto.ts index 1507563..aafa402 100644 --- a/BE/src/users/dto/usersUpdate.dto.ts +++ b/BE/src/users/dto/usersUpdate.dto.ts @@ -5,7 +5,7 @@ export class UpdateUsersDto { @IsString() nickname?: string; - @IsOptional() // 이 필드는 선택적으로 업데이트할 수 있도록 설정 - @IsBoolean() - is_image_changed?: string; + // @IsOptional() // 이 필드는 선택적으로 업데이트할 수 있도록 설정 + // @IsBoolean() + // is_image_changed?: string; } diff --git a/BE/src/users/user.repository.ts b/BE/src/users/user.repository.ts new file mode 100644 index 0000000..c7eca96 --- /dev/null +++ b/BE/src/users/user.repository.ts @@ -0,0 +1,20 @@ +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { BaseRepository } from '../common/base.repository'; +import { DataSource } from 'typeorm'; +import { REQUEST } from '@nestjs/core'; +import { UserEntity } from '../entities/user.entity'; + +@Injectable({ scope: Scope.REQUEST }) +export class UserRepository extends BaseRepository { + constructor(dataSource: DataSource, @Inject(REQUEST) req: Request) { + super(dataSource, req); + } + + async softDeleteCascade(userId: string) { + const user = await this.getRepository(UserEntity).findOne({ + where: { user_hash: userId }, + relations: ['blocker_post', 'blocker'], + }); + await this.getRepository(UserEntity).softRemove(user); + } +} diff --git a/BE/src/users/users.controller.ts b/BE/src/users/users.controller.ts index 659ef20..56bc671 100644 --- a/BE/src/users/users.controller.ts +++ b/BE/src/users/users.controller.ts @@ -6,7 +6,6 @@ import { Param, Delete, UseInterceptors, - ValidationPipe, UploadedFile, HttpException, UseGuards, @@ -14,18 +13,24 @@ import { Headers, } from '@nestjs/common'; import { UsersService } from './users.service'; -import { CreateUserDto } from './dto/createUser.dto'; import { FileInterceptor } from '@nestjs/platform-express'; import { MultiPartBody } from 'src/common/decorator/multiPartBody.decorator'; import { UpdateUsersDto } from './dto/usersUpdate.dto'; import { AuthGuard } from 'src/common/guard/auth.guard'; import { UserHash } from '../common/decorator/auth.decorator'; import { FileSizeValidator } from '../common/files.validator'; +import { ImageService } from '../image/image.service'; +import { NotificationService } from '../notification/notification.service'; +import { TransactionInterceptor } from '../common/interceptor/transaction.interceptor'; @Controller('users') @UseGuards(AuthGuard) export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly imageService: ImageService, + private readonly notificationService: NotificationService, + ) {} @Get(':id') async usersDetails(@Param('id') userId) { @@ -37,32 +42,16 @@ export class UsersController { } } - @Post() - @UseInterceptors(FileInterceptor('profileImage')) - async usersCreate( - @UploadedFile(new FileSizeValidator()) - file: Express.Multer.File, - @MultiPartBody( - 'profile', - new ValidationPipe({ validateCustomDecorators: true }), - ) - createUserDto: CreateUserDto, - ) { - let imageLocation: string; - - if (file !== undefined) { - imageLocation = await this.usersService.uploadImages(file); - } - await this.usersService.createUser(imageLocation, createUserDto); - } - @Delete(':id') + @UseInterceptors(TransactionInterceptor) async usersRemove( @Param('id') id: string, @UserHash() userId: string, @Headers('authorization') token: string, ) { - await this.usersService.removeUser(id, userId, token); + await this.usersService.checkAuth(id, userId); + await this.usersService.removeUser(userId, token); + await this.notificationService.removeRegistrationToken(userId); } @Patch(':id') @@ -73,7 +62,12 @@ export class UsersController { @UploadedFile(new FileSizeValidator()) file: Express.Multer.File, @UserHash() userId, ) { - await this.usersService.updateUserById(id, body, file, userId); + await this.usersService.checkAuth(id, userId); + const imageLocation = file + ? await this.imageService.uploadImage(file) + : null; + const nickname = body ? body.nickname : null; + await this.usersService.updateUserById(nickname, imageLocation, userId); } @Post('registration-token') @@ -81,6 +75,6 @@ export class UsersController { @Body('registration_token') registrationToken: string, @UserHash() userId: string, ) { - await this.usersService.registerToken(userId, registrationToken); + await this.notificationService.registerToken(userId, registrationToken); } } diff --git a/BE/src/users/users.module.ts b/BE/src/users/users.module.ts index ea1b8d4..f9c8d05 100644 --- a/BE/src/users/users.module.ts +++ b/BE/src/users/users.module.ts @@ -2,14 +2,14 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { S3Handler } from 'src/common/S3Handler'; import { UserEntity } from '../entities/user.entity'; import { BlockUserEntity } from '../entities/blockUser.entity'; import { BlockPostEntity } from '../entities/blockPost.entity'; import { AuthGuard } from 'src/common/guard/auth.guard'; import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; -import { FcmHandler } from 'src/common/fcmHandler'; -import { GreenEyeHandler } from '../common/greenEyeHandler'; +import { UserRepository } from './user.repository'; +import { ImageModule } from '../image/image.module'; +import { NotificationModule } from '../notification/notification.module'; @Module({ imports: [ @@ -19,8 +19,10 @@ import { GreenEyeHandler } from '../common/greenEyeHandler'; BlockPostEntity, RegistrationTokenEntity, ]), + ImageModule, + NotificationModule, ], controllers: [UsersController], - providers: [UsersService, S3Handler, AuthGuard, FcmHandler, GreenEyeHandler], + providers: [UsersService, AuthGuard, UserRepository], }) export class UsersModule {} diff --git a/BE/src/users/users.service.spec.ts b/BE/src/users/users.service.spec.ts new file mode 100644 index 0000000..a416be9 --- /dev/null +++ b/BE/src/users/users.service.spec.ts @@ -0,0 +1,164 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersService } from './users.service'; +import { UserRepository } from './user.repository'; +import { ConfigService } from '@nestjs/config'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { HttpException } from '@nestjs/common'; +import { UserEntity } from '../entities/user.entity'; +const mockRepository = { + save: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), +}; + +const mockUserRepository = { + getRepository: jest.fn().mockReturnValue(mockRepository), + softDeleteCascade: jest.fn(), +}; + +jest.mock('jsonwebtoken', () => ({ + decode: jest.fn(() => { + return { + exp: 1703128397, + }; + }), +})); + +describe('UsersService', function () { + let service: UsersService; + let repository; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: UserRepository, + useValue: mockUserRepository, + }, + { + provide: ConfigService, + useValue: { get: jest.fn((key: string) => 'default image') }, + }, + { + provide: CACHE_MANAGER, + useValue: { set: jest.fn((key: string) => 'mocked-value') }, + }, + ], + }).compile(); + + service = module.get(UsersService); + repository = module.get(UserRepository); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('checkAuth', function () { + it('should return 404', function () { + repository.getRepository().findOne.mockResolvedValue(null); + expect(async () => { + await service.checkAuth('user', 'user'); + }).rejects.toThrowError( + new HttpException('유저가 존재하지 않습니다.', 404), + ); + }); + + it('should return 403', function () { + repository.getRepository().findOne.mockResolvedValue(new UserEntity()); + expect(async () => { + await service.checkAuth('user', 'user1'); + }).rejects.toThrowError(new HttpException('수정 권한이 없습니다.', 403)); + }); + + it('should pass', async function () { + repository.getRepository().findOne.mockResolvedValue(new UserEntity()); + await service.checkAuth('user', 'user'); + }); + }); + + describe('findUserById', function () { + it('should return null', async function () { + repository.getRepository().findOne.mockResolvedValue(null); + const res = await service.findUserById('user'); + expect(res).toEqual(null); + }); + + it('should return user with profile image', async function () { + const user = new UserEntity(); + user.profile_img = 'www.test.com'; + user.nickname = 'user'; + repository.getRepository().findOne.mockResolvedValue(user); + const res = await service.findUserById('user'); + expect(res.nickname).toEqual('user'); + expect(res.profile_img).toEqual('www.test.com'); + }); + + it('should return user with default profile image', async function () { + const user = new UserEntity(); + user.profile_img = null; + user.nickname = 'user'; + repository.getRepository().findOne.mockResolvedValue(user); + const res = await service.findUserById('user'); + expect(res.nickname).toEqual('user'); + expect(res.profile_img).toEqual('default image'); + }); + }); + + describe('updateUserById', function () { + it('should update only nickname', async function () { + const nickname = 'test'; + const imageLocation = null; + const userId = 'user'; + const updateEntity = new UserEntity(); + updateEntity.nickname = nickname; + updateEntity.profile_img = undefined; + await service.updateUserById(nickname, imageLocation, userId); + expect(repository.getRepository().update).toHaveBeenCalledWith( + { + user_hash: userId, + }, + updateEntity, + ); + }); + + it('should update only image', async function () { + const nickname = null; + const imageLocation = 'test'; + const userId = 'user'; + const updateEntity = new UserEntity(); + updateEntity.nickname = undefined; + updateEntity.profile_img = imageLocation; + await service.updateUserById(nickname, imageLocation, userId); + expect(repository.getRepository().update).toHaveBeenCalledWith( + { + user_hash: userId, + }, + updateEntity, + ); + }); + + it('should update all', async function () { + const nickname = 'test'; + const imageLocation = 'test'; + const userId = 'user'; + const updateEntity = new UserEntity(); + updateEntity.nickname = nickname; + updateEntity.profile_img = imageLocation; + await service.updateUserById(nickname, imageLocation, userId); + expect(repository.getRepository().update).toHaveBeenCalledWith( + { + user_hash: userId, + }, + updateEntity, + ); + }); + }); + + describe('removeUser', function () { + it('should remove', async function () { + jest.spyOn(Math, 'floor').mockReturnValue(1703128360); + await service.removeUser('user', 'token'); + }); + }); +}); diff --git a/BE/src/users/users.service.ts b/BE/src/users/users.service.ts index 1a6cd8e..ce547e6 100644 --- a/BE/src/users/users.service.ts +++ b/BE/src/users/users.service.ts @@ -1,36 +1,18 @@ import { HttpException, Inject, Injectable } from '@nestjs/common'; import { CreateUserDto } from './dto/createUser.dto'; -import { InjectRepository } from '@nestjs/typeorm'; -import { UserEntity } from 'src/entities/user.entity'; -import { Repository } from 'typeorm'; -import { UpdateUsersDto } from './dto/usersUpdate.dto'; -import { S3Handler } from '../common/S3Handler'; -import { hashMaker } from 'src/common/hashMaker'; -import { BlockUserEntity } from '../entities/blockUser.entity'; -import { BlockPostEntity } from '../entities/blockPost.entity'; -import { RegistrationTokenEntity } from '../entities/registrationToken.entity'; +import { UserEntity } from '../entities/user.entity'; +import { hashMaker } from '../common/hashMaker'; import { ConfigService } from '@nestjs/config'; import * as jwt from 'jsonwebtoken'; -import { FcmHandler } from 'src/common/fcmHandler'; import { CACHE_MANAGER, CacheStore } from '@nestjs/cache-manager'; -import { GreenEyeHandler } from '../common/greenEyeHandler'; +import { UserRepository } from './user.repository'; @Injectable() export class UsersService { constructor( - @InjectRepository(UserEntity) - private userRepository: Repository, - @InjectRepository(BlockUserEntity) - private blockUserRepository: Repository, - @InjectRepository(BlockPostEntity) - private blockPostRepository: Repository, - @InjectRepository(RegistrationTokenEntity) - private registrationTokenRepository: Repository, + private userRepository: UserRepository, @Inject(CACHE_MANAGER) private cacheManager: CacheStore, - private s3Handler: S3Handler, private configService: ConfigService, - private fcmHandler: FcmHandler, - private greenEyeHandler: GreenEyeHandler, ) {} async createUser(imageLocation: string, createUserDto: CreateUserDto) { @@ -40,116 +22,58 @@ export class UsersService { userEntity.OAuth_domain = createUserDto.OAuth_domain; userEntity.profile_img = imageLocation; userEntity.user_hash = hashMaker(createUserDto.nickname).slice(0, 8); - return await this.userRepository.save(userEntity); + return await this.userRepository.getRepository(UserEntity).save(userEntity); } async findUserById(userId: string) { - const user: UserEntity = await this.userRepository.findOne({ - where: { user_hash: userId }, - }); + const user: UserEntity = await this.userRepository + .getRepository(UserEntity) + .findOne({ + where: { user_hash: userId }, + }); if (user) { - if (user.profile_img === null) { - user.profile_img = this.configService.get('DEFAULT_PROFILE_IMAGE'); - } + user.profile_img = + user.profile_img ?? this.configService.get('DEFAULT_PROFILE_IMAGE'); return { nickname: user.nickname, profile_img: user.profile_img }; } else { return null; } } - async removeUser(id: string, userId: string, accessToken: string) { - const userPk = await this.checkAuth(id, userId); + async removeUser(userId: string, accessToken: string) { const decodedToken: any = jwt.decode(accessToken); if (decodedToken && decodedToken.exp) { - await this.fcmHandler.removeRegistrationToken(decodedToken.userId); const ttl: number = decodedToken.exp - Math.floor(Date.now() / 1000); await this.cacheManager.set(accessToken, 'logout', { ttl }); } - - await this.deleteCascadingUser(userPk, userId); - return true; - } - - async deleteCascadingUser(userId, userHash) { - await this.blockPostRepository.softDelete({ blocker: userHash }); - await this.blockUserRepository.softDelete({ blocker: userHash }); - await this.userRepository.softDelete({ id: userId }); + await this.userRepository.softDeleteCascade(userId); } async checkAuth(id, userId) { - const isDataExists = await this.userRepository.findOne({ - where: { user_hash: id }, - }); + const isDataExists = await this.userRepository + .getRepository(UserEntity) + .findOne({ + where: { user_hash: id }, + }); if (!isDataExists) { throw new HttpException('유저가 존재하지 않습니다.', 404); } if (id !== userId) { throw new HttpException('수정 권한이 없습니다.', 403); } - return isDataExists.id; } async updateUserById( - id: string, - body: UpdateUsersDto, - file: Express.Multer.File, + nickname: string, + imageLocation: string, userId: string, ) { - await this.checkAuth(id, userId); - if (file !== undefined) { - await this.changeImages(id, file); - } - if (body) { - await this.changeNickname(id, body.nickname); - } - } - - async changeNickname(userId: string, nickname: string) { - await this.userRepository.update( - { user_hash: userId }, - { nickname: nickname }, - ); - } - - async changeImages(userId: string, file: Express.Multer.File) { - const fileLocation = await this.s3Handler.uploadFile(file); - const isHarmful = await this.greenEyeHandler.isHarmful(fileLocation); - // if (isHarmful) { - // throw new HttpException('이미지가 유해합니다.', 400); - // } - await this.userRepository.update( - { user_hash: userId }, - { profile_img: fileLocation }, - ); - } - - async uploadImages(file: Express.Multer.File) { - return await this.s3Handler.uploadFile(file); - } - - async registerToken(userId, registrationToken) { - const registrationTokenEntity = - await this.registrationTokenRepository.findOne({ - where: { user_hash: userId }, - }); - if (registrationTokenEntity === null) { - await this.registrationTokenRepository.save({ - user_hash: userId, - registration_token: registrationToken, - }); - } else { - await this.updateRegistrationToken(userId, registrationToken); - } - } + const user = new UserEntity(); + user.nickname = nickname ?? undefined; + user.profile_img = imageLocation ?? undefined; - async updateRegistrationToken(userId, registrationToken) { - const registrationTokenEntity = new RegistrationTokenEntity(); - registrationTokenEntity.registration_token = registrationToken; - await this.registrationTokenRepository.update( - { - user_hash: userId, - }, - registrationTokenEntity, - ); + await this.userRepository + .getRepository(UserEntity) + .update({ user_hash: userId }, user); } }