diff --git a/README.md b/README.md index 43c706c..f5f559b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ slack의 서버, 채널 기능과 zoom의 인스턴스 화상채팅 기능을 |------|---|---|------| | |||| + +## 주요 기능 +### WebRTC를 이용한 실시간 화상, 음성채팅 기능 +![webrtc](https://user-images.githubusercontent.com/76931330/144179395-9a706456-062f-420c-92da-71f82211de1c.gif) + +### WebSpeech API를 이용한 Speech-To-Text 기능 +![stt](https://user-images.githubusercontent.com/76931330/144179406-eae4235d-eb2d-47ae-a9ca-0c08bb31af8c.gif) + +### Slack과 유사한 방식의 텍스트 채팅 서버, 채널 관리 +![channel](https://user-images.githubusercontent.com/76931330/144179409-844f97f5-403c-4a9c-a15a-1290a53b366f.gif) + + ## 기술 스택 | division | stack | | --------------- | --------------------------------- | @@ -24,13 +36,12 @@ slack의 서버, 채널 기능과 zoom의 인스턴스 화상채팅 기능을 [기술스택 상세]((https://github.com/boostcampwm-2021/web07-boostCam/wiki/Tech-Stack)) -## 기획서 -[기획서 링크](https://docs.google.com/presentation/d/1CMu3LHJmwsUydwi0grAzuaGcXTTmatm5mvMyuJue2Rs/edit?usp=sharing) ## 위키 [위키](https://github.com/boostcampwm-2021/web07-boostCam/wiki)에 가시면 아래 내용들을 확인할 수 있습니다 + - 기획서 - 목업 - 그라운드 룰 - - 데일리 스크럼 + - 데일리 스크럼 기록 - 회의록 - 백로그 diff --git a/backend/.env.timezone b/backend/.env.timezone new file mode 100644 index 0000000..02fdee7 --- /dev/null +++ b/backend/.env.timezone @@ -0,0 +1 @@ +TZ=Asia/Seoul diff --git a/backend/package-lock.json b/backend/package-lock.json index 75542e5..7eb01f2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "@nestjs/core": "^8.0.0", "@nestjs/platform-express": "^8.0.0", "@nestjs/platform-socket.io": "^8.1.2", + "@nestjs/swagger": "^5.1.5", "@nestjs/typeorm": "^8.0.2", "@nestjs/websockets": "^8.1.2", "aws-sdk": "^2.1033.0", @@ -27,6 +28,7 @@ "rimraf": "^3.0.2", "rxjs": "^7.2.0", "socket.io": "^4.3.1", + "swagger-ui-express": "^4.1.6", "typeorm": "^0.2.40" }, "devDependencies": { @@ -1582,6 +1584,17 @@ } } }, + "node_modules/@nestjs/mapped-types": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.0.0.tgz", + "integrity": "sha512-26AW5jHadLXtvHs+M+Agd9KZ92dDlBrmD0rORlBlvn2KvsWs4JRaKl2mUsrW7YsdZeAu3Hc4ukqyYyDdyCmMWQ==", + "peerDependencies": { + "@nestjs/common": "^7.0.8 || ^8.0.0", + "class-transformer": "^0.2.0 || ^0.3.0 || ^0.4.0", + "class-validator": "^0.11.1 || ^0.12.0 || ^0.13.0", + "reflect-metadata": "^0.1.12" + } + }, "node_modules/@nestjs/platform-express": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-8.1.2.tgz", @@ -1636,6 +1649,31 @@ "typescript": "^3.4.5 || ^4.3.5" } }, + "node_modules/@nestjs/swagger": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-5.1.5.tgz", + "integrity": "sha512-jvsgciVEFcYVVEuLdNAmgJDpFSQzIoukg+Ovz1vJ97OeCprx0sVhmocn13nCl/dEwIkwoM3eby9OiaccX0A+3A==", + "dependencies": { + "@nestjs/mapped-types": "1.0.0", + "lodash": "4.17.21", + "path-to-regexp": "3.2.0" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0", + "@nestjs/core": "^8.0.0", + "fastify-swagger": "*", + "reflect-metadata": "^0.1.12", + "swagger-ui-express": "*" + }, + "peerDependenciesMeta": { + "fastify-swagger": { + "optional": true + }, + "swagger-ui-express": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.1.2.tgz", @@ -3306,6 +3344,22 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", + "integrity": "sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==", + "peer": true + }, + "node_modules/class-validator": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz", + "integrity": "sha512-yBUcQy07FPlGzUjoLuUfIOXzgynnQPPruyK1Ge2B74k9ROwnle1E+NxLWnUv5OLU8hA/qL5leAE9XnXq3byaBw==", + "peer": true, + "dependencies": { + "libphonenumber-js": "^1.9.43", + "validator": "^13.7.0" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -6687,6 +6741,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.9.43", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.43.tgz", + "integrity": "sha512-tNB87ZutAiAkl3DE/Bo0Mxqn/XZbNxhPg4v9bYBwQQW4dlhBGqXl1vtmPxeDWbrijzwOA9vRjOOFm5V9SK/W3w==", + "peer": true + }, "node_modules/lines-and-columns": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", @@ -6716,8 +6776,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.get": { "version": "4.4.2", @@ -8651,6 +8710,25 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "3.52.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-3.52.5.tgz", + "integrity": "sha512-8z18eX8G/jbTXYzyNIaobrnD7PSN7yU/YkSasMmajrXtw0FGS64XjrKn5v37d36qmU3o1xLeuYnktshRr7uIFw==" + }, + "node_modules/swagger-ui-express": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.1.6.tgz", + "integrity": "sha512-Xs2BGGudvDBtL7RXcYtNvHsFtP1DBFPMJFRxHe5ez/VG/rzVOEjazJOOSc/kSCyxreCTKfJrII6MJlL9a6t8vw==", + "dependencies": { + "swagger-ui-dist": "^3.18.1" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -9498,6 +9576,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", + "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -11144,6 +11231,12 @@ "uuid": "8.3.2" } }, + "@nestjs/mapped-types": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.0.0.tgz", + "integrity": "sha512-26AW5jHadLXtvHs+M+Agd9KZ92dDlBrmD0rORlBlvn2KvsWs4JRaKl2mUsrW7YsdZeAu3Hc4ukqyYyDdyCmMWQ==", + "requires": {} + }, "@nestjs/platform-express": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-8.1.2.tgz", @@ -11178,6 +11271,16 @@ "pluralize": "8.0.0" } }, + "@nestjs/swagger": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-5.1.5.tgz", + "integrity": "sha512-jvsgciVEFcYVVEuLdNAmgJDpFSQzIoukg+Ovz1vJ97OeCprx0sVhmocn13nCl/dEwIkwoM3eby9OiaccX0A+3A==", + "requires": { + "@nestjs/mapped-types": "1.0.0", + "lodash": "4.17.21", + "path-to-regexp": "3.2.0" + } + }, "@nestjs/testing": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-8.1.2.tgz", @@ -12503,6 +12606,22 @@ "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==", "dev": true }, + "class-transformer": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.4.0.tgz", + "integrity": "sha512-ETWD/H2TbWbKEi7m9N4Km5+cw1hNcqJSxlSYhsLsNjQzWWiZIYA1zafxpK9PwVfaZ6AqR5rrjPVUBGESm5tQUA==", + "peer": true + }, + "class-validator": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.13.2.tgz", + "integrity": "sha512-yBUcQy07FPlGzUjoLuUfIOXzgynnQPPruyK1Ge2B74k9ROwnle1E+NxLWnUv5OLU8hA/qL5leAE9XnXq3byaBw==", + "peer": true, + "requires": { + "libphonenumber-js": "^1.9.43", + "validator": "^13.7.0" + } + }, "cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -15065,6 +15184,12 @@ "type-check": "~0.4.0" } }, + "libphonenumber-js": { + "version": "1.9.43", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.9.43.tgz", + "integrity": "sha512-tNB87ZutAiAkl3DE/Bo0Mxqn/XZbNxhPg4v9bYBwQQW4dlhBGqXl1vtmPxeDWbrijzwOA9vRjOOFm5V9SK/W3w==", + "peer": true + }, "lines-and-columns": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", @@ -15088,8 +15213,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.get": { "version": "4.4.2", @@ -16569,6 +16693,19 @@ "supports-color": "^7.0.0" } }, + "swagger-ui-dist": { + "version": "3.52.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-3.52.5.tgz", + "integrity": "sha512-8z18eX8G/jbTXYzyNIaobrnD7PSN7yU/YkSasMmajrXtw0FGS64XjrKn5v37d36qmU3o1xLeuYnktshRr7uIFw==" + }, + "swagger-ui-express": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-4.1.6.tgz", + "integrity": "sha512-Xs2BGGudvDBtL7RXcYtNvHsFtP1DBFPMJFRxHe5ez/VG/rzVOEjazJOOSc/kSCyxreCTKfJrII6MJlL9a6t8vw==", + "requires": { + "swagger-ui-dist": "^3.18.1" + } + }, "symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -17127,6 +17264,12 @@ "source-map": "^0.7.3" } }, + "validator": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.7.0.tgz", + "integrity": "sha512-nYXQLCBkpJ8X6ltALua9dRrZDHVYxjJ1wgskNt1lH9fzGjs3tgojGSCBjmEPwkWS1y29+DrizMTW19Pr9uB2nw==", + "peer": true + }, "vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/backend/package.json b/backend/package.json index e5c15f9..83bb4d5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "@nestjs/core": "^8.0.0", "@nestjs/platform-express": "^8.0.0", "@nestjs/platform-socket.io": "^8.1.2", + "@nestjs/swagger": "^5.1.5", "@nestjs/typeorm": "^8.0.2", "@nestjs/websockets": "^8.1.2", "aws-sdk": "^2.1033.0", @@ -39,6 +40,7 @@ "rimraf": "^3.0.2", "rxjs": "^7.2.0", "socket.io": "^4.3.1", + "swagger-ui-express": "^4.1.6", "typeorm": "^0.2.40" }, "devDependencies": { diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts deleted file mode 100644 index d22f389..0000000 --- a/backend/src/app.controller.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - providers: [AppService], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); - }); - }); -}); diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts deleted file mode 100644 index cce879e..0000000 --- a/backend/src/app.controller.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { AppService } from './app.service'; - -@Controller() -export class AppController { - constructor(private readonly appService: AppService) {} - - @Get() - getHello(): string { - return this.appService.getHello(); - } -} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5a0d9b8..c21ae52 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import ormConfig from './config/ormconfig'; -import { AppController } from './app.controller'; import { AppService } from './app.service'; import { CamModule } from './cam/cam.module'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -23,7 +22,7 @@ import githubConfig from './config/github.config'; imports: [ ConfigModule.forRoot({ load: [githubConfig], - envFilePath: ['.env', '.env.github', '.env.redis'], + envFilePath: ['.env', '.env.github', '.env.redis', '.env.timezone'], isGlobal: true, }), TypeOrmModule.forRoot(ormConfig()), @@ -39,7 +38,6 @@ import githubConfig from './config/github.config'; UserChannelModule, ImageModule, ], - controllers: [AppController], providers: [AppService, MessageGateway], }) export class AppModule {} diff --git a/backend/src/cam/cam-inner.service.ts b/backend/src/cam/cam-inner.service.ts index 3e53153..d0fbfc2 100644 --- a/backend/src/cam/cam-inner.service.ts +++ b/backend/src/cam/cam-inner.service.ts @@ -1,6 +1,5 @@ -import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Status, CamMap } from '../types/cam'; -import { CamService } from './cam.service'; type RoomId = string; type SocketId = string; @@ -17,10 +16,7 @@ export class CamInnerService { private map: Map>; private sharedScreen: Map; - constructor( - @Inject(forwardRef(() => CamService)) - private readonly camService: CamService, - ) { + constructor() { this.map = new Map(); this.sharedScreen = new Map(); } @@ -55,12 +51,7 @@ export class CamInnerService { exitRoom(roomId: string, userId: string) { if (!this.map.get(roomId)) return false; const room = this.map.get(roomId).filter((user) => user.userId !== userId); - if (room.length == 0) { - this.map.delete(roomId); - this.camService.deleteCam(roomId); - } else { - this.map.set(roomId, room); - } + this.map.set(roomId, room); } updateStatus(roomId: string, userId: string, status: Status) { @@ -108,4 +99,8 @@ export class CamInnerService { const room = this.map.get(roomId); return room && room.length < MAX_PEOPLE; } + + deleteRoom(roomId: RoomId) { + this.map.delete(roomId); + } } diff --git a/backend/src/cam/cam.controller.ts b/backend/src/cam/cam.controller.ts index 6b954d5..ed5d824 100644 --- a/backend/src/cam/cam.controller.ts +++ b/backend/src/cam/cam.controller.ts @@ -1,17 +1,34 @@ -import { Controller, Post, Body, Get, Param } from '@nestjs/common'; +import { + Controller, + Post, + Body, + Get, + Param, + Session, + Delete, + ForbiddenException, + UseGuards, + ParseIntPipe, +} from '@nestjs/common'; import ResponseEntity from '../common/response-entity'; -import { CreateCamDto } from './cam.dto'; +import { LoginGuard } from '../login/login.guard'; +import { ExpressSession } from '../types/session'; +import { RequestCamDto } from './cam.dto'; import { Cam } from './cam.entity'; import { CamService } from './cam.service'; -@Controller('api/cam') +@Controller('/api/cam') export class CamController { constructor(private camService: CamService) {} - @Post() async createCam( - @Body() cam: CreateCamDto, + @UseGuards(LoginGuard) + @Post() + async createCam( + @Body() cam: RequestCamDto, + @Session() session: ExpressSession, ): Promise> { + cam.userId = session.user?.id; const savedCam = await this.camService.createCam(cam); return ResponseEntity.created(savedCam.id); @@ -24,4 +41,21 @@ export class CamController { return ResponseEntity.ok(cam); } + + @UseGuards(LoginGuard) + @Delete('/:id') async deleteCam( + @Param('id', new ParseIntPipe()) id: number, + @Session() session: ExpressSession, + ): Promise> { + const cam = await this.camService.findOneById(id); + const isOwner = session.user?.id == cam.ownerId; + + if (!isOwner) { + throw new ForbiddenException(); + } + + await this.camService.deleteCam(id); + + return ResponseEntity.noContent(); + } } diff --git a/backend/src/cam/cam.dto.ts b/backend/src/cam/cam.dto.ts index a12c848..e7ae283 100644 --- a/backend/src/cam/cam.dto.ts +++ b/backend/src/cam/cam.dto.ts @@ -1,19 +1,22 @@ import { Cam } from './cam.entity'; -export type CreateCamDto = { +export type RequestCamDto = { name: string; serverId: number; + userId: number | null; }; export class ResponseCamDto { + id: number; name: string; url: string; - constructor(name: string, url: string) { + constructor(id: number, name: string, url: string) { + this.id = id; this.name = name; this.url = url; } static fromEntry(cam: Cam) { - return new ResponseCamDto(cam.name, cam.url); + return new ResponseCamDto(cam.id, cam.name, cam.url); } } diff --git a/backend/src/cam/cam.entity.ts b/backend/src/cam/cam.entity.ts index 96fcc99..8ca6ba0 100644 --- a/backend/src/cam/cam.entity.ts +++ b/backend/src/cam/cam.entity.ts @@ -7,10 +7,11 @@ import { } from 'typeorm'; import { Server } from '../server/server.entity'; +import { User } from '../user/user.entity'; @Entity() export class Cam { - @PrimaryGeneratedColumn({ type: 'bigint' }) + @PrimaryGeneratedColumn() id: number; @Column() @@ -19,9 +20,15 @@ export class Cam { @Column() url: string; - @ManyToOne(() => Server) + @ManyToOne(() => Server, { onDelete: 'CASCADE' }) server: Server; + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + owner: User; + @RelationId((cam: Cam) => cam.server) serverId: number; + + @RelationId((cam: Cam) => cam.owner) + ownerId: number; } diff --git a/backend/src/cam/cam.gateway.ts b/backend/src/cam/cam.gateway.ts index 49221b9..e5fda9a 100644 --- a/backend/src/cam/cam.gateway.ts +++ b/backend/src/cam/cam.gateway.ts @@ -8,7 +8,7 @@ import { Socket, Server } from 'socket.io'; import { Status, MessageInfo } from '../types/cam'; import { CamInnerService } from './cam-inner.service'; -@WebSocketGateway() +@WebSocketGateway({ namespace: '/cam' }) export class CamGateway { @WebSocketServer() server: Server; constructor(private camInnerService: CamInnerService) {} @@ -117,14 +117,14 @@ export class CamGateway { }); } - @SubscribeMessage('sendMessage') + @SubscribeMessage('sendCamMessage') handleSendMessage(client: Socket, payload: MessageInfo): void { if (!client.data.roomId || !client.data.userId) return; const { roomId } = client.data; const nicknameInfo = this.camInnerService.getRoomNicknameList(roomId); client.broadcast .to(roomId) - .emit('receiveMessage', { payload, nicknameInfo }); + .emit('receiveCamMessage', { payload, nicknameInfo }); } @SubscribeMessage('changeNickname') diff --git a/backend/src/cam/cam.module.ts b/backend/src/cam/cam.module.ts index 2e797da..d594be8 100644 --- a/backend/src/cam/cam.module.ts +++ b/backend/src/cam/cam.module.ts @@ -9,10 +9,19 @@ import { Cam } from './cam.entity'; import { ServerRepository } from '../server/server.repository'; import { CamRepository } from './cam.repository'; import { Server } from '../server/server.entity'; +import { UserRepository } from '../user/user.repository'; +import { User } from '../user/user.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Cam, Server, CamRepository, ServerRepository]), + TypeOrmModule.forFeature([ + Cam, + Server, + CamRepository, + ServerRepository, + User, + UserRepository, + ]), ], providers: [CamGateway, CamInnerService, CamService], controllers: [CamController], diff --git a/backend/src/cam/cam.service.ts b/backend/src/cam/cam.service.ts index 1439e3b..8a36d86 100644 --- a/backend/src/cam/cam.service.ts +++ b/backend/src/cam/cam.service.ts @@ -1,25 +1,24 @@ import { BadRequestException, ForbiddenException, - forwardRef, - Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { v4 } from 'uuid'; import { ServerRepository } from '../server/server.repository'; -import { CreateCamDto, ResponseCamDto } from './cam.dto'; +import { RequestCamDto, ResponseCamDto } from './cam.dto'; import { Cam } from './cam.entity'; import { CamRepository } from './cam.repository'; import { CamInnerService } from './cam-inner.service'; +import { UserRepository } from '../user/user.repository'; @Injectable() export class CamService { constructor( private camRepository: CamRepository, private serverRepository: ServerRepository, - @Inject(forwardRef(() => CamInnerService)) + private userRepository: UserRepository, private readonly camInnerService: CamInnerService, ) { this.camRepository.clear(); @@ -41,7 +40,17 @@ export class CamService { return cam; } - async createCam(cam: CreateCamDto): Promise { + async findOneById(id: number): Promise { + const cam = await this.camRepository.findOne({ id: id }); + + if (!cam) { + throw new NotFoundException(); + } + + return cam; + } + + async createCam(cam: RequestCamDto): Promise { const camEntity = this.camRepository.create(); const server = await this.serverRepository.findOne({ id: cam.serverId, @@ -51,9 +60,16 @@ export class CamService { throw new BadRequestException(); } + const user = await this.userRepository.findOne({ id: cam?.userId }); + + if (!cam.userId || !user) { + throw new ForbiddenException(); + } + camEntity.name = cam.name; camEntity.server = server; camEntity.url = v4(); + camEntity.owner = user; const savedCam = await this.camRepository.save(camEntity); this.camInnerService.createRoom(camEntity.url); @@ -61,8 +77,16 @@ export class CamService { return savedCam; } - async deleteCam(url: string): Promise { - await this.camRepository.delete({ url: url }); + async deleteCam(id: number): Promise { + const camEntity = await this.camRepository.findOne({ id: id }); + + if (!camEntity) { + throw new BadRequestException(); + } + + this.camInnerService.deleteRoom(camEntity.url); + + await this.camRepository.delete({ id: camEntity.id }); } async getCamList(serverId: number): Promise { diff --git a/backend/src/channel/channe.dto.ts b/backend/src/channel/channe.dto.ts deleted file mode 100644 index 80134d7..0000000 --- a/backend/src/channel/channe.dto.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type ChannelFormDto = { - name: string; - description: string; - serverId: number; -}; diff --git a/backend/src/channel/channel-form.dto.ts b/backend/src/channel/channel-form.dto.ts new file mode 100644 index 0000000..d343e92 --- /dev/null +++ b/backend/src/channel/channel-form.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ChannelFormDto { + @ApiProperty() + name: string; + + @ApiProperty() + description: string; + + @ApiProperty() + serverId: number; +} diff --git a/backend/src/channel/channel.controller.ts b/backend/src/channel/channel.controller.ts index 25b2e9b..967cd46 100644 --- a/backend/src/channel/channel.controller.ts +++ b/backend/src/channel/channel.controller.ts @@ -8,17 +8,18 @@ import { Patch, Session, UseGuards, + ParseIntPipe, } from '@nestjs/common'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { ChannelService } from './channel.service'; import { Channel } from './channel.entity'; -import { ChannelFormDto } from './channe.dto'; +import { ChannelFormDto } from './channel-form.dto'; import { UserChannelService } from '../user-channel/user-channel.service'; import ResponseEntity from '../common/response-entity'; -@Controller('api/channel') +@Controller('/api/channels') @UseGuards(LoginGuard) export class ChannelController { constructor( @@ -28,36 +29,49 @@ export class ChannelController { this.channelService = channelService; this.userChannelService = userChannelService; } - @Get() async findAll(): Promise> { - const channelList = await this.channelService.findAll(); - return ResponseEntity.ok(channelList); - } - @Get(':id') async findOne( - @Param('id') id: number, + @Get(':channelId') async findOne( + @Param('channelId', new ParseIntPipe()) id: number, ): Promise> { - const foundServer = await this.channelService.findOne(id); - return ResponseEntity.ok(foundServer); + const foundChannel = await this.channelService.findOne(id); + return ResponseEntity.ok(foundChannel); + } + + @Get(':channelId/auth') async checkAuthority( + @Param('channelId', new ParseIntPipe()) id: number, + @Session() session: ExpressSession, + ): Promise> { + const foundChannel = await this.channelService.findOne(id); + return ResponseEntity.ok(foundChannel.ownerId === session.user.id); } + @Post() async saveChannel( @Body() channel: ChannelFormDto, @Session() session: ExpressSession, ): Promise> { - const savedChannel = await this.channelService.createChannel(channel); + const savedChannel = await this.channelService.createChannel( + channel, + session.user.id, + ); await this.userChannelService.addNewChannel(savedChannel, session.user.id); return ResponseEntity.ok(savedChannel); } - @Patch(':id') async updateUser( - @Param('id') id: number, + @Patch(':channelId') async updateChannel( + @Param('channelId', new ParseIntPipe()) id: number, @Body() channel: ChannelFormDto, + @Session() session: ExpressSession, ): Promise> { - const changedChannel = await this.channelService.updateChannel(id, channel); + const changedChannel = await this.channelService.updateChannel( + id, + channel, + session.user.id, + ); return ResponseEntity.ok(changedChannel); } - @Delete(':id') async deleteChannel( - @Param('id') id: number, - ): Promise> { + @Delete(':channelId') async deleteChannel( + @Param('channelId', new ParseIntPipe()) id: number, + ): Promise> { await this.channelService.deleteChannel(id); - return ResponseEntity.ok(id); + return ResponseEntity.noContent(); } } diff --git a/backend/src/channel/channel.entity.ts b/backend/src/channel/channel.entity.ts index 489ffc4..eece1b8 100644 --- a/backend/src/channel/channel.entity.ts +++ b/backend/src/channel/channel.entity.ts @@ -1,9 +1,18 @@ import { Server } from '../server/server.entity'; -import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + RelationId, + OneToMany, +} from 'typeorm'; +import { User } from '../user/user.entity'; +import { UserChannel } from '../user-channel/user-channel.entity'; @Entity() export class Channel { - @PrimaryGeneratedColumn({ type: 'bigint' }) + @PrimaryGeneratedColumn() id: number; @Column() @@ -12,6 +21,32 @@ export class Channel { @Column() description: string; - @ManyToOne(() => Server) + @ManyToOne(() => Server, { onDelete: 'CASCADE' }) server: Server; + + @ManyToOne(() => User) + owner: User; + + @OneToMany(() => UserChannel, (userChannels) => userChannels.channel) + userChannels: UserChannel[]; + + @RelationId((channel: Channel) => channel.owner) + ownerId: number; + + @RelationId((channel: Channel) => channel.server) + serverId: number; + + static newInstance( + name: string, + description: string, + server: Server, + owner: User, + ) { + const newChannel = new Channel(); + newChannel.name = name; + newChannel.description = description; + newChannel.server = server; + newChannel.owner = owner; + return newChannel; + } } diff --git a/backend/src/channel/channel.module.ts b/backend/src/channel/channel.module.ts index 0998999..e16672c 100644 --- a/backend/src/channel/channel.module.ts +++ b/backend/src/channel/channel.module.ts @@ -9,12 +9,14 @@ import { UserChannelService } from '../user-channel/user-channel.service'; import { UserChannelRepository } from '../user-channel/user-channel.repository'; import { UserRepository } from '../user/user.repository'; import { ChannelRepository } from './channel.repository'; +import { User } from '../user/user.entity'; @Module({ imports: [ TypeOrmModule.forFeature([ Channel, Server, + User, UserChannelRepository, UserRepository, ChannelRepository, diff --git a/backend/src/channel/channel.repository.ts b/backend/src/channel/channel.repository.ts index 1745651..1da1c87 100644 --- a/backend/src/channel/channel.repository.ts +++ b/backend/src/channel/channel.repository.ts @@ -6,4 +6,28 @@ export class ChannelRepository extends Repository { getAllList() { return this.createQueryBuilder('channel').getMany(); } + + getChannelListByServerId(serverId: number) { + return this.createQueryBuilder('channel') + .where('channel.serverId = :serverId', { serverId }) + .getMany(); + } + + getJoinedChannelList(userId: number, serverId: number) { + return this.createQueryBuilder('channel') + .leftJoin('channel.userChannels', 'user_channel') + .where('channel.serverId = :serverId', { serverId }) + .andWhere('user_channel.userId = :userId', { userId }) + .getMany(); + } + + getNotJoinedChannelList(userId: number, serverId: number) { + return this.createQueryBuilder('channel') + .where('channel.serverId = :serverId', { serverId }) + .andWhere( + 'channel.id NOT IN (select uc.channelId from user_channel uc where uc.userId = :userId)', + { userId }, + ) + .getMany(); + } } diff --git a/backend/src/channel/channel.service.ts b/backend/src/channel/channel.service.ts index d440acb..231b41d 100644 --- a/backend/src/channel/channel.service.ts +++ b/backend/src/channel/channel.service.ts @@ -1,39 +1,45 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm/index'; -import { ChannelFormDto } from './channe.dto'; +import { ChannelFormDto } from './channel-form.dto'; import { Channel } from './channel.entity'; import { Server } from '../server/server.entity'; import { ChannelRepository } from './channel.repository'; +import { UserRepository } from '../user/user.repository'; +import { User } from '../user/user.entity'; @Injectable() export class ChannelService { - /** * 생성자 */ constructor( + constructor( @InjectRepository(Channel) private channelRepository: ChannelRepository, + @InjectRepository(User) private userRepository: UserRepository, @InjectRepository(Server) private serverRepository: Repository, - ) { - this.channelRepository = channelRepository; - this.serverRepository = serverRepository; - } - findAll(): Promise { - return this.channelRepository.find(); - } - findOne(id: number): Promise { - return this.channelRepository.findOne( + ) {} + async findOne(id: number): Promise { + const response = await this.channelRepository.findOne( { id: id }, - { relations: ['server'] }, + { relations: ['server', 'owner'] }, ); + if (!response) throw new NotFoundException('서버가 존재하지 않습니다!'); + return response; } - async createChannel(channel: ChannelFormDto): Promise { - const channelEntity = await this.createChannelEntity(channel); + async createChannel( + channel: ChannelFormDto, + userId: number, + ): Promise { + const channelEntity = await this.createChannelEntity(channel, userId); const savedChannel = await this.channelRepository.save(channelEntity); return savedChannel; } - async updateChannel(id: number, channel: ChannelFormDto): Promise { - const channelEntity = await this.createChannelEntity(channel); + async updateChannel( + id: number, + channel: ChannelFormDto, + userId: number, + ): Promise { + const channelEntity = await this.createChannelEntity(channel, userId); await this.channelRepository.update(id, channelEntity); return channelEntity; } @@ -42,17 +48,22 @@ export class ChannelService { await this.channelRepository.delete({ id: id }); } - async createChannelEntity(channel: ChannelFormDto): Promise { - const channelEntity = this.channelRepository.create(); + async createChannelEntity( + channel: ChannelFormDto, + userId: number, + ): Promise { + const { name, description, serverId } = channel; const server = await this.serverRepository.findOne({ - id: channel.serverId, + id: serverId, + }); + const user = await this.userRepository.findOne({ + id: userId, }); - if (!server) throw new BadRequestException(); + if (!server) throw new NotFoundException('서버가 존재하지 않습니다!'); + if (!user) throw new NotFoundException('사용자가 존재하지 않습니다!'); - channelEntity.name = channel.name; - channelEntity.description = channel.description; - channelEntity.server = server; - return channelEntity; + const newChannel = Channel.newInstance(name, description, server, user); + return newChannel; } } diff --git a/backend/src/channel/dto/channel-response.dto.ts b/backend/src/channel/dto/channel-response.dto.ts new file mode 100644 index 0000000..0611718 --- /dev/null +++ b/backend/src/channel/dto/channel-response.dto.ts @@ -0,0 +1,35 @@ +import { Channel } from '../channel.entity'; + +class ChannelResponseDto { + id: number; + name: string; + description: string; + ownerId: number; + serverId: number; + + constructor( + id: number, + name: string, + description: string, + ownerId: number, + serverId: number, + ) { + this.id = id; + this.name = name; + this.description = description; + this.ownerId = ownerId; + this.serverId = serverId; + } + + static fromEntity(channel: Channel) { + return new ChannelResponseDto( + channel.id, + channel.name, + channel.description, + channel.ownerId, + channel.serverId, + ); + } +} + +export default ChannelResponseDto; diff --git a/backend/src/comment/comment.controller.ts b/backend/src/comment/comment.controller.ts index c8b5468..dcb1c39 100644 --- a/backend/src/comment/comment.controller.ts +++ b/backend/src/comment/comment.controller.ts @@ -1,32 +1,29 @@ -import { Body, Controller, Get, Post, Query, Session } from '@nestjs/common'; +import { + Controller, + Get, + ParseIntPipe, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiExtraModels, ApiOkResponse } from '@nestjs/swagger'; import ResponseEntity from '../common/response-entity'; -import { ExpressSession } from '../types/session'; +import { LoginGuard } from '../login/login.guard'; import { CommentDto } from './comment.dto'; +import { commentDtoSchema } from './comment.schema'; import { CommentService } from './comment.service'; @Controller('/api/comments') +@ApiExtraModels(ResponseEntity) +@ApiExtraModels(CommentDto) +@UseGuards(LoginGuard) export class CommentController { constructor(private commentService: CommentService) {} - @Post() - async sendComment( - @Session() session: ExpressSession, - @Body('channelId') channelId: number, - @Body('messageId') messageId: number, - @Body('contents') contents: string, - ): Promise> { - const sender = session.user; - const newComment = await this.commentService.sendComment( - sender.id, - channelId, - messageId, - contents, - ); - return ResponseEntity.ok(newComment); - } - + @ApiOkResponse(commentDtoSchema) @Get() - async findCommentsByMessageId(@Query('messageId') messageId: number) { + async findCommentsByMessageId( + @Query('messageId', new ParseIntPipe()) messageId: number, + ) { const comments = await this.commentService.findCommentsByMessageId( messageId, ); diff --git a/backend/src/comment/comment.dto.ts b/backend/src/comment/comment.dto.ts index 217db72..42a897d 100644 --- a/backend/src/comment/comment.dto.ts +++ b/backend/src/comment/comment.dto.ts @@ -1,11 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; import { UserDto } from '../user/user.dto'; import { Comment } from './comment.entity'; export class CommentDto { + @ApiProperty() id: number; + + @ApiProperty() contents: string; + + @ApiProperty() createdAt: Date; + + @ApiProperty() sender: UserDto; + + @ApiProperty() messageId: number; static newInstance( diff --git a/backend/src/comment/comment.entity.ts b/backend/src/comment/comment.entity.ts index 149b8f5..ccf1e9e 100644 --- a/backend/src/comment/comment.entity.ts +++ b/backend/src/comment/comment.entity.ts @@ -11,7 +11,7 @@ import { User } from '../user/user.entity'; @Entity() export class Comment { - @PrimaryGeneratedColumn({ type: 'bigint' }) + @PrimaryGeneratedColumn() id: number; @Column() @@ -26,7 +26,7 @@ export class Comment { @RelationId((comment: Comment) => comment.sender) senderId: number; - @ManyToOne(() => Message) + @ManyToOne(() => Message, { onDelete: 'CASCADE' }) message: Message; @RelationId((comment: Comment) => comment.message) diff --git a/backend/src/comment/comment.repository.ts b/backend/src/comment/comment.repository.ts index 2ced5ee..d1c6704 100644 --- a/backend/src/comment/comment.repository.ts +++ b/backend/src/comment/comment.repository.ts @@ -7,6 +7,7 @@ export class CommentRepository extends Repository { return this.createQueryBuilder('comment') .innerJoinAndSelect('comment.sender', 'user') .where('comment.messageId = :messageId', { messageId }) + .orderBy('comment.id') .getMany(); } } diff --git a/backend/src/comment/comment.schema.ts b/backend/src/comment/comment.schema.ts new file mode 100644 index 0000000..d3eb163 --- /dev/null +++ b/backend/src/comment/comment.schema.ts @@ -0,0 +1,20 @@ +import { getSchemaPath } from '@nestjs/swagger'; + +import ResponseEntity from '../common/response-entity'; +import { CommentDto } from './comment.dto'; + +export const commentDtoSchema = { + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseEntity) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(CommentDto) }, + }, + }, + }, + ], + }, +}; diff --git a/backend/src/common/response-entity.ts b/backend/src/common/response-entity.ts index 963c646..3a896e5 100644 --- a/backend/src/common/response-entity.ts +++ b/backend/src/common/response-entity.ts @@ -1,5 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + class ResponseEntity { + @ApiProperty() statusCode: number; + @ApiProperty() message: string; data: T; constructor(statusCode: number, message: string, data: T) { diff --git a/backend/src/emoticon/emoticon.entity.ts b/backend/src/emoticon/emoticon.entity.ts index bd30550..78f7b27 100644 --- a/backend/src/emoticon/emoticon.entity.ts +++ b/backend/src/emoticon/emoticon.entity.ts @@ -2,7 +2,7 @@ import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; @Entity() export class Emoticon { - @PrimaryGeneratedColumn({ type: 'bigint' }) + @PrimaryGeneratedColumn() id: number; @Column() diff --git a/backend/src/login/login.controller.ts b/backend/src/login/login.controller.ts index 0f3a5b6..026b67a 100644 --- a/backend/src/login/login.controller.ts +++ b/backend/src/login/login.controller.ts @@ -1,20 +1,25 @@ import { Controller, Get, Query, Session } from '@nestjs/common'; +import { ApiOkResponse } from '@nestjs/swagger'; +import ResponseEntity from '../common/response-entity'; import { ExpressSession } from '../types/session'; +import { UserDto } from '../user/user.dto'; import { LoginService } from './login.service'; @Controller('/api/login') export class LoginController { constructor(private loginService: LoginService) {} + + @ApiOkResponse({ type: UserDto }) @Get('/github') async githubLogin( @Session() session: ExpressSession, @Query('code') code: string, - ) { + ): Promise> { const user = await this.loginService.githubLogin(code); session.user = user; session.save(); - return user; + return ResponseEntity.noContent(); } } diff --git a/backend/src/main.ts b/backend/src/main.ts index cf83543..a904c70 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,6 +1,7 @@ import { NestFactory } from '@nestjs/core'; import { ExpressPeerServer } from 'peer'; import { ConfigService } from '@nestjs/config'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; import { createSessionMiddleware } from './session'; @@ -13,6 +14,13 @@ async function bootstrap() { const configService = app.get(ConfigService); const session = createSessionMiddleware(configService); + const config = new DocumentBuilder() + .setTitle('boostCam API') + .setDescription('boostCam API description') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + app.use(session); app.useWebSocketAdapter(new MessageSessionAdapter(app, session)); app.use('/peerjs', peerServer); diff --git a/backend/src/message.adapter.ts b/backend/src/message.adapter.ts index 9867e39..6620310 100644 --- a/backend/src/message.adapter.ts +++ b/backend/src/message.adapter.ts @@ -3,19 +3,15 @@ import { Server } from 'socket.io'; import { INestApplication } from '@nestjs/common'; export class MessageSessionAdapter extends IoAdapter { - private app: INestApplication; private sessionMiddleware; constructor(app: INestApplication, sessionMiddleware) { super(app); - this.app = app; this.sessionMiddleware = sessionMiddleware; } createIOServer(port: number, options?: any): any { const server: Server = super.createIOServer(port, options); - - this.app.use(this.sessionMiddleware); server.use((socket, next) => { this.sessionMiddleware(socket.request, {}, next); }); diff --git a/backend/src/message.gateway.ts b/backend/src/message.gateway.ts index a7dea01..e278226 100644 --- a/backend/src/message.gateway.ts +++ b/backend/src/message.gateway.ts @@ -4,6 +4,7 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { CommentService } from './comment/comment.service'; import { MessageDto } from './message/message.dto'; import { MessageService } from './message/message.service'; import { ExpressSession } from './types/session'; @@ -15,7 +16,7 @@ declare module 'http' { } } -@WebSocketGateway({ namespace: '/message' }) +@WebSocketGateway() export class MessageGateway { @WebSocketServer() private server: Server; @@ -23,6 +24,7 @@ export class MessageGateway { constructor( private userChannelService: UserChannelService, private messageService: MessageService, + private commentService: CommentService, ) {} @SubscribeMessage('joinChannels') @@ -66,6 +68,25 @@ export class MessageGateway { this.emitMessage(channelId, newMessage); } + @SubscribeMessage('sendComment') + async handleSendComment( + client: Socket, + payload: { channelId: number; messageId: number; contents: string }, + ) { + if (!this.checkLoginSession(client)) { + return; + } + const { channelId, messageId, contents } = payload; + const sender = client.request.session.user; + const newComment = await this.commentService.sendComment( + sender.id, + channelId, + messageId, + contents, + ); + this.server.to(`${channelId}`).emit('receiveComment', newComment); + } + private checkLoginSession(client: Socket): boolean { const user = client.request.session.user; return !!user; diff --git a/backend/src/message/message.controller.ts b/backend/src/message/message.controller.ts index 1e7d24b..8433d1c 100644 --- a/backend/src/message/message.controller.ts +++ b/backend/src/message/message.controller.ts @@ -1,59 +1,32 @@ import { - Body, Controller, Get, - Post, + ParseIntPipe, Query, Session, UseGuards, } from '@nestjs/common'; +import { ApiExtraModels, ApiOkResponse } from '@nestjs/swagger'; import ResponseEntity from '../common/response-entity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { MessageDto } from './message.dto'; +import { MessageDtoSchema } from './message.scheme'; import { MessageService } from './message.service'; @Controller('/api/messages') @UseGuards(LoginGuard) +@ApiExtraModels(ResponseEntity) +@ApiExtraModels(MessageDto) export class MessageController { constructor(private messageService: MessageService) {} - @Post() - async sendMessage( - @Session() session: ExpressSession, - @Body('channelId') channelId: number, - @Body('contents') contents: string, - ): Promise> { - const sender = session.user; - const newMessage = await this.messageService.sendMessage( - sender.id, - channelId, - contents, - ); - - return ResponseEntity.ok(newMessage); - } - - @Get('/getone') - async findMessageByMessageId( - @Session() session: ExpressSession, - @Query('messageId') messageId: number, - @Query('channelId') channelId: number, - ) { - const sender = session.user; - const selectedMessage = await this.messageService.findMessagesByMessageId( - sender.id, - channelId, - messageId, - ); - return ResponseEntity.ok(selectedMessage); - } - + @ApiOkResponse(MessageDtoSchema) @Get() async findMessagesByChannelId( @Session() session: ExpressSession, - @Query('channelId') channelId: number, - ) { + @Query('channelId', new ParseIntPipe()) channelId: number, + ): Promise> { const sender = session.user; const channelMessages = await this.messageService.findMessagesByChannelId( sender.id, diff --git a/backend/src/message/message.dto.ts b/backend/src/message/message.dto.ts index 29d4df3..dfdb9d1 100644 --- a/backend/src/message/message.dto.ts +++ b/backend/src/message/message.dto.ts @@ -1,11 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; import { UserDto } from '../user/user.dto'; import { Message } from './message.entity'; export class MessageDto { + @ApiProperty() id: number; + @ApiProperty() contents: string; + @ApiProperty() channelId: number; + @ApiProperty() createdAt: Date; + @ApiProperty() sender: UserDto; static newInstance( diff --git a/backend/src/message/message.entity.ts b/backend/src/message/message.entity.ts index e73a7ec..19941f7 100644 --- a/backend/src/message/message.entity.ts +++ b/backend/src/message/message.entity.ts @@ -11,13 +11,13 @@ import { @Entity() export class Message { - @PrimaryGeneratedColumn({ type: 'bigint' }) + @PrimaryGeneratedColumn() id: number; @Column() contents: string; - @ManyToOne(() => Channel) + @ManyToOne(() => Channel, { onDelete: 'CASCADE' }) channel: Channel; @RelationId((message: Message) => message.channel) diff --git a/backend/src/message/message.repository.ts b/backend/src/message/message.repository.ts index 492fd8c..5908f47 100644 --- a/backend/src/message/message.repository.ts +++ b/backend/src/message/message.repository.ts @@ -7,6 +7,7 @@ export class MessageRepository extends Repository { return this.createQueryBuilder('message') .innerJoinAndSelect('message.sender', 'user') .where('message.channelId = :channelId', { channelId }) + .orderBy('message.id') .getMany(); } } diff --git a/backend/src/message/message.scheme.ts b/backend/src/message/message.scheme.ts new file mode 100644 index 0000000..31356d0 --- /dev/null +++ b/backend/src/message/message.scheme.ts @@ -0,0 +1,20 @@ +import { getSchemaPath } from '@nestjs/swagger'; + +import ResponseEntity from '../common/response-entity'; +import { MessageDto } from './message.dto'; + +export const MessageDtoSchema = { + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseEntity) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(MessageDto) }, + }, + }, + }, + ], + }, +}; diff --git a/backend/src/message/message.service.ts b/backend/src/message/message.service.ts index 5a5f1f7..4f46c25 100644 --- a/backend/src/message/message.service.ts +++ b/backend/src/message/message.service.ts @@ -38,14 +38,4 @@ export class MessageService { const messages = await this.messageRepository.findByChannelId(channelId); return messages.map(MessageDto.fromEntity); } - - async findMessagesByMessageId( - senderId: number, - channelId: number, - messageId: number, - ) { - await this.userServerService.checkUserChannelAccess(senderId, channelId); - const message = await this.messageRepository.findOne(messageId); - return MessageDto.fromEntity(message); - } } diff --git a/backend/src/server/dto/request-server.dto.ts b/backend/src/server/dto/request-server.dto.ts index 37256c8..6c24b60 100644 --- a/backend/src/server/dto/request-server.dto.ts +++ b/backend/src/server/dto/request-server.dto.ts @@ -1,7 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; import { Server } from '../server.entity'; class RequestServerDto { + @ApiProperty() name: string; + + @ApiProperty() description: string; constructor(name: string, description: string) { diff --git a/backend/src/server/dto/response-server-users.dto.ts b/backend/src/server/dto/response-server-users.dto.ts index ce32672..d143817 100644 --- a/backend/src/server/dto/response-server-users.dto.ts +++ b/backend/src/server/dto/response-server-users.dto.ts @@ -1,21 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; import { Server } from '../../server/server.entity'; - -type UserInfo = { - nickname: string; - profile: string; -}; +import { UserDto } from '../../user/user.dto'; class ServerWithUsersDto { + @ApiProperty() description: string; + + @ApiProperty() name: string; + + @ApiProperty() imgUrl: string; - users: UserInfo[]; + + @ApiProperty() + users: UserDto[]; constructor( description: string, name: string, imgUrl: string, - users: UserInfo[], + users: UserDto[], ) { this.description = description; this.name = name; @@ -28,12 +32,13 @@ class ServerWithUsersDto { server.description, server.name, server.imgUrl, - server.userServer.map((userServer) => { - return { - nickname: userServer.user.nickname, - profile: userServer.user.profile, - }; - }), + server.userServer.map((userServer) => + UserDto.newInstance( + userServer.user.id, + userServer.user.nickname, + userServer.user.profile, + ), + ), ); } } diff --git a/backend/src/server/server.controller.ts b/backend/src/server/server.controller.ts index d420e3a..87820e4 100644 --- a/backend/src/server/server.controller.ts +++ b/backend/src/server/server.controller.ts @@ -8,11 +8,11 @@ import { Patch, UseGuards, Session, - HttpException, UseInterceptors, UploadedFile, HttpCode, HttpStatus, + ParseIntPipe, } from '@nestjs/common'; import { ServerService } from './server.service'; @@ -25,8 +25,19 @@ import { ImageService } from '../image/image.service'; import ServerWithUsersDto from './dto/response-server-users.dto'; import { CamService } from '../cam/cam.service'; import { ResponseCamDto } from '../cam/cam.dto'; +import { ApiExtraModels, ApiOkResponse } from '@nestjs/swagger'; +import { UserDto } from '../user/user.dto'; +import { + serverWithUserDtoSchema, + serverCodeSchema, + emptyResponseSchema, +} from './server.schema'; +@UseGuards(LoginGuard) @Controller('/api/servers') +@ApiExtraModels(ResponseEntity) +@ApiExtraModels(ServerWithUsersDto) +@ApiExtraModels(UserDto) export class ServerController { constructor( private serverService: ServerService, @@ -34,36 +45,44 @@ export class ServerController { private camService: CamService, ) {} - @Get('/:id/users') async findOneWithUsers( - @Param('id') id: number, + @ApiOkResponse(serverWithUserDtoSchema) + @Get('/:id/users') + async findOneWithUsers( + @Param('id', new ParseIntPipe()) id: number, ): Promise> { const serverWithUsers = await this.serverService.findOneWithUsers(id); return ResponseEntity.ok(serverWithUsers); } @Get('/:id/cam') async findCams( - @Param('id') id: number, + @Param('id', new ParseIntPipe()) id: number, ): Promise> { const cam = await this.camService.getCamList(id); return ResponseEntity.ok(cam); } - @Get('/:id/code') async findCode( - @Param('id') id: number, + @ApiOkResponse(serverCodeSchema) + @Get('/:id/code') + async findCode( + @Param('id', new ParseIntPipe()) id: number, ): Promise> { const code = await this.serverService.findCode(id); return ResponseEntity.ok(code); } - @Patch('/:id/code') async refreshCode( - @Param('id') id: number, + @ApiOkResponse(serverCodeSchema) + @Patch('/:id/code') + async refreshCode( + @Session() + session: ExpressSession, + @Param('id', new ParseIntPipe()) id: number, ): Promise> { - const code = await this.serverService.refreshCode(id); + const user = session.user; + const code = await this.serverService.refreshCode(id, user); return ResponseEntity.ok(code); } @Post() - @UseGuards(LoginGuard) @UseInterceptors(FileInterceptor('icon')) async createServer( @Session() @@ -71,82 +90,64 @@ export class ServerController { @Body() requestServerDto: RequestServerDto, @UploadedFile() icon: Express.Multer.File, ): Promise> { - try { - requestServerDto = new RequestServerDto( - requestServerDto.name, - requestServerDto.description, - ); - let imgUrl: string; + requestServerDto = new RequestServerDto( + requestServerDto.name, + requestServerDto.description, + ); + let imgUrl: string; - if (icon && icon.mimetype.substring(0, 5) === 'image') { - const uploadedFile = await this.imageService.uploadFile(icon); - imgUrl = uploadedFile.Location; - } - const user = session.user; - const newServer = await this.serverService.create( - user, - requestServerDto, - imgUrl, - ); - return ResponseEntity.created(newServer.id); - } catch (error) { - if (error instanceof HttpException) { - throw error; - } - throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + if (icon && icon.mimetype.substring(0, 5) === 'image') { + const uploadedFile = await this.imageService.uploadFile(icon); + imgUrl = uploadedFile.Location; } + const user = session.user; + const newServerId = await this.serverService.create( + user, + requestServerDto, + imgUrl, + ); + + return ResponseEntity.created(newServerId); } + @ApiOkResponse(serverCodeSchema) @Patch('/:id') @HttpCode(HttpStatus.NO_CONTENT) @UseInterceptors(FileInterceptor('icon')) async updateServer( @Session() session: ExpressSession, - @Param('id') id: number, + @Param('id', new ParseIntPipe()) id: number, @Body() requestServerDto: RequestServerDto, @UploadedFile() icon: Express.Multer.File, ): Promise> { - try { - requestServerDto = new RequestServerDto( - requestServerDto.name, - requestServerDto.description, - ); - let imgUrl: string; - - if (icon && icon.mimetype.substring(0, 5) === 'image') { - const uploadedFile = await this.imageService.uploadFile(icon); - imgUrl = uploadedFile.Location; - } - const user = session.user; + requestServerDto = new RequestServerDto( + requestServerDto.name, + requestServerDto.description, + ); + let imgUrl: string; - await this.serverService.updateServer(id, requestServerDto, user, imgUrl); - - return ResponseEntity.noContent(); - } catch (error) { - if (error instanceof HttpException) { - throw error; - } - throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + if (icon && icon.mimetype.substring(0, 5) === 'image') { + const uploadedFile = await this.imageService.uploadFile(icon); + imgUrl = uploadedFile.Location; } + const user = session.user; + await this.serverService.updateServer(id, requestServerDto, user, imgUrl); + + return ResponseEntity.noContent(); } + @ApiOkResponse(emptyResponseSchema) @Delete('/:id') @HttpCode(HttpStatus.NO_CONTENT) async deleteServer( @Session() session: ExpressSession, - @Param('id') id: number, + @Param('id', new ParseIntPipe()) id: number, ): Promise> { - try { - const user = session.user; - await this.serverService.deleteServer(id, user); - return ResponseEntity.noContent(); - } catch (error) { - if (error instanceof HttpException) { - throw error; - } - throw new HttpException(error.message, HttpStatus.BAD_REQUEST); - } + const user = session.user; + await this.serverService.deleteServer(id, user); + + return ResponseEntity.noContent(); } } diff --git a/backend/src/server/server.entity.ts b/backend/src/server/server.entity.ts index 472026c..b999f20 100644 --- a/backend/src/server/server.entity.ts +++ b/backend/src/server/server.entity.ts @@ -6,12 +6,13 @@ import { ManyToOne, JoinColumn, OneToMany, + RelationId, } from 'typeorm'; import { User } from '../user/user.entity'; @Entity() export class Server { - @PrimaryGeneratedColumn({ type: 'bigint' }) + @PrimaryGeneratedColumn() id: number; @Column() @@ -30,6 +31,9 @@ export class Server { @JoinColumn({ referencedColumnName: 'id' }) owner: User; + @RelationId((server: Server) => server.owner) + ownerId: number; + @OneToMany(() => UserServer, (userServer) => userServer.server) userServer: UserServer[]; diff --git a/backend/src/server/server.schema.ts b/backend/src/server/server.schema.ts new file mode 100644 index 0000000..7b1d0ae --- /dev/null +++ b/backend/src/server/server.schema.ts @@ -0,0 +1,60 @@ +import { getSchemaPath } from '@nestjs/swagger'; + +import ResponseEntity from '../common/response-entity'; +import { UserDto } from '../user/user.dto'; +import ServerWithUsersDto from './dto/response-server-users.dto'; + +export const serverWithUserDtoSchema = { + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseEntity) }, + { + properties: { + data: { + allOf: [ + { $ref: getSchemaPath(ServerWithUsersDto) }, + { + properties: { + users: { + type: 'array', + items: { $ref: getSchemaPath(UserDto) }, + }, + }, + }, + ], + }, + }, + }, + ], + }, +}; + +export const serverCodeSchema = { + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseEntity) }, + { + properties: { + data: { + type: 'string', + }, + }, + }, + ], + }, +}; + +export const emptyResponseSchema = { + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseEntity) }, + { + properties: { + data: { + type: null, + }, + }, + }, + ], + }, +}; diff --git a/backend/src/server/server.service.spec.ts b/backend/src/server/server.service.spec.ts index e99a895..9b0ef19 100644 --- a/backend/src/server/server.service.spec.ts +++ b/backend/src/server/server.service.spec.ts @@ -47,6 +47,8 @@ describe('ServerService', () => { const existsServerId = 1; const userId = 1; + const newServerId = 2; + const newUserServerId = 1; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -126,26 +128,40 @@ describe('ServerService', () => { describe('refreshCode()', () => { it('코드 재생성에 성공할 경우', async () => { - serverRepository.findOne.mockResolvedValue(existsServer); + serverRepository.findOneWithOwner.mockResolvedValue(existsServer); const originCode = existsServer.code; - const code = await serverService.refreshCode(existsServerId); + const code = await serverService.refreshCode(existsServerId, user); expect(code).not.toBe(originCode); }); it('서버가 존재하지 않을 경우', async () => { const nonExistsId = 0; - serverRepository.findOne.mockResolvedValue(undefined); + serverRepository.findOneWithOwner.mockResolvedValue(undefined); try { - await serverService.refreshCode(nonExistsId); + await serverService.refreshCode(nonExistsId, user); } catch (error) { expect(error.response.message).toBe('존재하지 않는 서버입니다.'); expect(error.response.error).toBe('Bad Request'); expect(error.response.statusCode).toBe(HttpStatus.BAD_REQUEST); } }); + + it('권한이 없을 경우', async () => { + const userNotOwner = new User(); + userNotOwner.id = 0; + serverRepository.findOneWithOwner.mockResolvedValue(existsServer); + + try { + await serverService.refreshCode(existsServerId, userNotOwner); + } catch (error) { + expect(error.response.message).toBe('권한이 없습니다.'); + expect(error.response.error).toBe('Forbidden'); + expect(error.response.statusCode).toBe(HttpStatus.FORBIDDEN); + } + }); }); describe('create()', () => { @@ -153,17 +169,15 @@ describe('ServerService', () => { serverRepository.save.mockResolvedValue(newServer); serverRepository.findOne.mockResolvedValue(newServer); userServerRepository.findByUserIdAndServerId.mockResolvedValue(undefined); - userServerRepository.save.mockResolvedValue(newUserServer); + userServerRepository.save.mockResolvedValue(newUserServerId); - const createdServer = await serverService.create( + const createdServerId = await serverService.create( user, requestServerDto, '', ); - expect(createdServer.name).toBe(requestServerDto.name); - expect(createdServer.description).toBe(requestServerDto.description); - expect(createdServer.owner.id).toBe(user.id); + expect(createdServerId).toBe(newServerId); }); }); @@ -267,6 +281,7 @@ describe('ServerService', () => { user.id = userId; requestServerDto = new RequestServerDto(serverName, serverDescription); newServer = new Server(); + newServer.id = newServerId; newServer.description = serverDescription; newServer.name = serverName; newServer.owner = user; @@ -280,5 +295,8 @@ describe('ServerService', () => { existsServer.owner = user; existsServer.userServer = [existsUserServer]; existsServer.code = v4(); + + newUserServer = new UserServer(); + newUserServer.id = newUserServerId; }; }); diff --git a/backend/src/server/server.service.ts b/backend/src/server/server.service.ts index 515717a..3c1a4fd 100644 --- a/backend/src/server/server.service.ts +++ b/backend/src/server/server.service.ts @@ -24,9 +24,6 @@ export class ServerService { @InjectRepository(ServerRepository) private serverRepository: ServerRepository, ) {} - findAll(): Promise { - return this.serverRepository.find({ relations: ['owner'] }); - } findOne(id: number): Promise { return this.serverRepository.findOne({ id: id }); @@ -52,12 +49,15 @@ export class ServerService { return server.code; } - async refreshCode(id: number): Promise { - const server = await this.serverRepository.findOne(id); + async refreshCode(id: number, user: User): Promise { + const server = await this.serverRepository.findOneWithOwner(id); if (!server) { throw new BadRequestException('존재하지 않는 서버입니다.'); } + if (server.owner.id !== user.id) { + throw new ForbiddenException('권한이 없습니다.'); + } server.code = v4(); this.serverRepository.save(server); @@ -68,7 +68,7 @@ export class ServerService { user: User, requestServerDto: RequestServerDto, imgUrl: string | undefined, - ): Promise { + ): Promise { const server = requestServerDto.toServerEntity(); server.owner = user; server.imgUrl = imgUrl || ''; @@ -77,7 +77,7 @@ export class ServerService { const createdServer = await this.serverRepository.save(server); this.userServerService.create(user, createdServer.code); - return createdServer; + return createdServer.id; } async updateServer( diff --git a/backend/src/user-channel/user-channel.controller.ts b/backend/src/user-channel/user-channel.controller.ts index e044400..5d221c3 100644 --- a/backend/src/user-channel/user-channel.controller.ts +++ b/backend/src/user-channel/user-channel.controller.ts @@ -7,6 +7,10 @@ import { UseGuards, Post, Body, + HttpStatus, + HttpException, + Query, + ParseIntPipe, } from '@nestjs/common'; import ResponseEntity from '../common/response-entity'; import { LoginGuard } from '../login/login.guard'; @@ -14,37 +18,33 @@ import { ExpressSession } from '../types/session'; import { UserChannelService } from './user-channel.service'; import { ChannelService } from '../channel/channel.service'; import { UserChannel } from './user-channel.entity'; -import { Channel } from '../channel/channel.entity'; +import { User } from '../user/user.entity'; +import ChannelResponseDto from '../channel/dto/channel-response.dto'; -@Controller('/api/user/servers') +@Controller('/api/user/servers/:serverId') @UseGuards(LoginGuard) export class UserChannelController { constructor( private userChannelService: UserChannelService, private channelService: ChannelService, - ) { - this.userChannelService = userChannelService; - this.channelService = channelService; - } + ) {} - @Get('/:id/channels/joined/') + @Get('/channels/joined/') async getJoinedChannelList( - @Param('id') serverId: number, + @Param('serverId', new ParseIntPipe()) serverId: number, @Session() session: ExpressSession, ) { - const response = await this.userChannelService.getJoinedChannelListByUserId( - serverId, - session.user.id, - ); - const joinedChannelList = response.map( - (userChannel) => userChannel.channel, - ); - return ResponseEntity.ok(joinedChannelList); + const joinedChannelList = + await this.userChannelService.getJoinedChannelListByUserId( + serverId, + session.user.id, + ); + return ResponseEntity.ok(joinedChannelList); } - @Get('/:id/channels/notjoined/') + @Get('/channels/notjoined/') async getNotJoinedChannelList( - @Param('id') serverId: number, + @Param('serverId', new ParseIntPipe()) serverId: number, @Session() session: ExpressSession, ) { const response = @@ -52,16 +52,26 @@ export class UserChannelController { serverId, session.user.id, ); - const notJoinedChannelList = response.map( - (userChannel) => userChannel.channel, - ); - return ResponseEntity.ok(notJoinedChannelList); + return ResponseEntity.ok(response); } - @Post() + @Get('/channels/users') + async getJoinedUserList( + @Param('serverId', new ParseIntPipe()) serverId: number, + @Query('channelId', new ParseIntPipe()) channelId: number, + ) { + const response = + await this.userChannelService.findJoinedUserListByChannelId( + serverId, + channelId, + ); + return ResponseEntity.ok(response); + } + + @Post('/channels') async joinNewChannel( @Body('channelId') channelId: number, - @Body('serverId') serverId: number, + @Param('serverId', new ParseIntPipe()) serverId: number, @Session() session: ExpressSession, ) { const selectedChannel = await this.channelService.findOne(channelId); @@ -72,8 +82,22 @@ export class UserChannelController { return ResponseEntity.ok(savedChannel); } - @Delete('/:id') - delete(@Param('id') id: number) { - return this.userChannelService.deleteById(id); + @Delete('/channels/:channelId') + async delete( + @Param('channelId', new ParseIntPipe()) channeld: number, + @Session() session: ExpressSession, + ) { + try { + this.userChannelService.deleteByUserIdAndChannelId( + session.user.id, + channeld, + ); + return ResponseEntity.noContent(); + } catch (error) { + if (error instanceof HttpException) { + throw error; + } + throw new HttpException(error.message, HttpStatus.BAD_REQUEST); + } } } diff --git a/backend/src/user-channel/user-channel.entity.ts b/backend/src/user-channel/user-channel.entity.ts index febba17..5d68c55 100644 --- a/backend/src/user-channel/user-channel.entity.ts +++ b/backend/src/user-channel/user-channel.entity.ts @@ -5,7 +5,7 @@ import { Channel } from '../channel/channel.entity'; @Entity() export class UserChannel { - @PrimaryGeneratedColumn({ type: 'bigint' }) + @PrimaryGeneratedColumn() id: number; @ManyToOne(() => User, { onDelete: 'CASCADE' }) diff --git a/backend/src/user-channel/user-channel.repository.ts b/backend/src/user-channel/user-channel.repository.ts index 740cab5..794f330 100644 --- a/backend/src/user-channel/user-channel.repository.ts +++ b/backend/src/user-channel/user-channel.repository.ts @@ -10,27 +10,26 @@ export class UserChannelRepository extends Repository { .getMany(); } - getJoinedChannelListByUserId(userId: number, serverId: number) { + getUserChannelListByUserId(userId: number) { return this.createQueryBuilder('user_channel') - .leftJoinAndSelect('user_channel.channel', 'channel') .where('user_channel.user = :userId', { userId: userId }) - .andWhere('user_channel.server = :serverId', { serverId: serverId }) .getMany(); } - getNotJoinedChannelListByUserId(userId: number, serverId: number) { + getJoinedUserListByChannelId(serverId: number, channelId: number) { return this.createQueryBuilder('user_channel') - .leftJoinAndSelect('user_channel.channel', 'channel') - .where('user_channel.channel IN (:id)', { - id: this.getJoinedChannelListByUserId(userId, serverId), - }) + .innerJoinAndSelect('user_channel.user', 'user') + .where('user_channel.channelId = :channelId', { channelId: channelId }) + .andWhere('user_channel.server = :serverId', { serverId: serverId }) .getMany(); } - deleteByUserIdAndChannelId(userId: number, channelId: number) { + getUserChannelByUserIdAndChannelId(userId: number, channelId: number) { return this.createQueryBuilder('user_channel') .where('user_channel.user = :userId', { userId: userId }) - .andWhere('user_channel.channelId = :channelId', { channelId: channelId }) - .delete(); + .andWhere('user_channel.channelId = :channelId', { + channelId: channelId, + }) + .getOne(); } } diff --git a/backend/src/user-channel/user-channel.service.ts b/backend/src/user-channel/user-channel.service.ts index e944906..7f23ff7 100644 --- a/backend/src/user-channel/user-channel.service.ts +++ b/backend/src/user-channel/user-channel.service.ts @@ -1,15 +1,19 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DeleteQueryBuilder, DeleteResult } from 'typeorm'; +import { DeleteResult } from 'typeorm'; +import { ChannelRepository } from '../channel/channel.repository'; import { UserChannelRepository } from './user-channel.repository'; import { UserChannel } from './user-channel.entity'; import { Channel } from '../channel/channel.entity'; import { UserRepository } from '../user/user.repository'; +import ChannelResponseDto from '../channel/dto/channel-response.dto'; @Injectable() export class UserChannelService { constructor( + @InjectRepository(ChannelRepository) + private channelRepository: ChannelRepository, @InjectRepository(UserChannelRepository) private userChannelRepository: UserChannelRepository, @InjectRepository(UserRepository) private userRepository: UserRepository, @@ -27,56 +31,56 @@ export class UserChannelService { return await this.userChannelRepository.save(userChannel); } - deleteById(id: number): Promise { - return this.userChannelRepository.delete(id); + deleteByChannelId(channelId: number): Promise { + return this.userChannelRepository.delete({ channelId }); } - getJoinedChannelListByUserId( + async getJoinedChannelListByUserId( serverId: number, userId: number, - ): Promise { - return this.userChannelRepository.getJoinedChannelListByUserId( + ): Promise { + const joinedChannelList = await this.channelRepository.getJoinedChannelList( userId, serverId, ); + + return joinedChannelList.map(ChannelResponseDto.fromEntity); } async getNotJoinedChannelListByUserId( serverId: number, userId: number, - ): Promise { - const allList = await this.userChannelRepository.getAllList(serverId); - const joinedList = - await this.userChannelRepository.getJoinedChannelListByUserId( - userId, - serverId, - ); - const joinedChannelList = joinedList.map( - (userChannel) => userChannel.channel.id, - ); + ): Promise { + const notJoinedChannelList = + await this.channelRepository.getNotJoinedChannelList(userId, serverId); - const notJoinedList = allList.filter( - (userChannel) => !joinedChannelList.includes(userChannel.channel.id), - ); - - return notJoinedList; + return notJoinedChannelList.map(ChannelResponseDto.fromEntity); } - deleteByUserIdAndChannelId( - userId: number, - serverId: number, - ): DeleteQueryBuilder { - return this.userChannelRepository.deleteByUserIdAndChannelId( - userId, - serverId, - ); + async deleteByUserIdAndChannelId(userId: number, channelId: number) { + const res = + await this.userChannelRepository.getUserChannelByUserIdAndChannelId( + userId, + channelId, + ); + this.userChannelRepository.delete({ id: res.id }); } async findChannelsByUserId(userId: number) { - const userChannels = await this.userChannelRepository - .createQueryBuilder('user_channel') - .where('user_channel.user = :userId', { userId: userId }) - .getMany(); + const userChannels = + await this.userChannelRepository.getUserChannelListByUserId(userId); return userChannels.map((uc) => uc.channelId.toString()); } + + async findJoinedUserListByChannelId(serverId: number, channelId: number) { + const joinedUserList = + await this.userChannelRepository.getJoinedUserListByChannelId( + serverId, + channelId, + ); + if (!joinedUserList) + throw new NotFoundException('채널에 사용자가 존재하지 않습니다!'); + const userList = joinedUserList.map((data) => data.user); + return userList; + } } diff --git a/backend/src/user-server/user-server.controller.ts b/backend/src/user-server/user-server.controller.ts index b748ed5..c8d4aa5 100644 --- a/backend/src/user-server/user-server.controller.ts +++ b/backend/src/user-server/user-server.controller.ts @@ -6,36 +6,42 @@ import { Param, Session, UseGuards, - HttpException, HttpCode, HttpStatus, + Get, + ParseIntPipe, } from '@nestjs/common'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; import { UserServerService } from './user-server.service'; import ResponseEntity from '../common/response-entity'; +import UserServerListDto from './dto/user-server-list.dto'; -@Controller('/api/users/servers') +@Controller('/api/user/servers') @UseGuards(LoginGuard) export class UserServerController { constructor(private userServerService: UserServerService) {} + @Get() + async getServersByUserId( + @Session() + session: ExpressSession, + ): Promise> { + const userId = session.user.id; + const data = await this.userServerService.getServerListByUserId(userId); + + return ResponseEntity.ok(data); + } + @Post() async createUserServer( @Session() session: ExpressSession, @Body() { code }, ) { - try { - const user = session.user; - const newUserServer = await this.userServerService.create(user, code); - return ResponseEntity.created(newUserServer.id); - } catch (error) { - if (error instanceof HttpException) { - throw error; - } - throw new HttpException(error.message, HttpStatus.BAD_REQUEST); - } + const user = session.user; + const newUserServerId = await this.userServerService.create(user, code); + return ResponseEntity.created(newUserServerId); } @Delete('/:id') @@ -43,17 +49,10 @@ export class UserServerController { async delete( @Session() session: ExpressSession, - @Param('id') id: number, + @Param('id', new ParseIntPipe()) id: number, ) { - try { - const userId = session.user.id; - await this.userServerService.deleteById(id, userId); - return ResponseEntity.noContent(); - } catch (error) { - if (error instanceof HttpException) { - throw error; - } - throw new HttpException(error.message, HttpStatus.BAD_REQUEST); - } + const userId = session.user.id; + await this.userServerService.deleteById(id, userId); + return ResponseEntity.noContent(); } } diff --git a/backend/src/user-server/user-server.entity.ts b/backend/src/user-server/user-server.entity.ts index db59aff..41d553c 100644 --- a/backend/src/user-server/user-server.entity.ts +++ b/backend/src/user-server/user-server.entity.ts @@ -4,7 +4,7 @@ import { Server } from '../server/server.entity'; @Entity() export class UserServer { - @PrimaryGeneratedColumn({ type: 'bigint' }) + @PrimaryGeneratedColumn() id: number; @ManyToOne(() => User, { onDelete: 'CASCADE' }) diff --git a/backend/src/user-server/user-server.service.spec.ts b/backend/src/user-server/user-server.service.spec.ts index 1533a0a..7d8e4de 100644 --- a/backend/src/user-server/user-server.service.spec.ts +++ b/backend/src/user-server/user-server.service.spec.ts @@ -42,6 +42,7 @@ describe('UserServerService', () => { const userId = 1; const serverId = 1; const existUserServerId = 1; + const userServerId = 2; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -75,10 +76,9 @@ describe('UserServerService', () => { serverRepository.findOne.mockResolvedValue(server); userServerRepository.findByUserIdAndServerId.mockResolvedValue(undefined); - const newUserServer = await service.create(user, server.code); + const newUserServerId = await service.create(user, server.code); - expect(newUserServer.user).toBe(user); - expect(newUserServer.server).toBe(server); + expect(newUserServerId).toBe(userServerId); }); it('서버가 존재하지 않는 경우', async () => { @@ -186,6 +186,7 @@ describe('UserServerService', () => { server.code = v4(); userServer = new UserServer(); + userServer.id = userServerId; userServer.user = user; userServer.server = server; diff --git a/backend/src/user-server/user-server.service.ts b/backend/src/user-server/user-server.service.ts index ce188e2..aeea010 100644 --- a/backend/src/user-server/user-server.service.ts +++ b/backend/src/user-server/user-server.service.ts @@ -22,7 +22,7 @@ export class UserServerService { private userServerRepository: UserServerRepository, ) {} - async create(user: User, code: string): Promise { + async create(user: User, code: string): Promise { const newUserServer = new UserServer(); newUserServer.user = user; newUserServer.server = await this.serverService.findByCode(code); @@ -38,7 +38,8 @@ export class UserServerService { throw new BadRequestException('이미 등록된 서버입니다.'); } - return this.userServerRepository.save(newUserServer); + const newUserServerId = await this.userServerRepository.save(newUserServer); + return newUserServerId.id; } async deleteById(id: number, userId: number): Promise { diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index 10e370e..a77ec61 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,28 +1,14 @@ import { Controller, Get, UseGuards, Session } from '@nestjs/common'; -import { UserServerService } from '../user-server/user-server.service'; +import ResponseEntity from '../common/response-entity'; import { LoginGuard } from '../login/login.guard'; import { ExpressSession } from '../types/session'; -import ResponseEntity from '../common/response-entity'; -import UserServerListDto from '../user-server/dto/user-server-list.dto'; +import { UserDto } from './user.dto'; @Controller('/api/user') @UseGuards(LoginGuard) export class UserController { - constructor(private userServerService: UserServerService) {} - - @Get('/servers') - async getServersByUserId( - @Session() - session: ExpressSession, - ): Promise> { - const userId = session.user.id; - const data = await this.userServerService.getServerListByUserId(userId); - - return ResponseEntity.ok(data); - } - @Get() getUser(@Session() session: ExpressSession) { - return session.user; + return ResponseEntity.ok(UserDto.fromEntity(session.user)); } } diff --git a/backend/src/user/user.dto.ts b/backend/src/user/user.dto.ts index 8c07a60..20eb460 100644 --- a/backend/src/user/user.dto.ts +++ b/backend/src/user/user.dto.ts @@ -1,8 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; import { User } from './user.entity'; export class UserDto { + @ApiProperty() id: number; + @ApiProperty() nickname: string; + @ApiProperty() profile: string; static newInstance(id: number, nickname: string, profile: string) { diff --git a/backend/src/user/user.entity.ts b/backend/src/user/user.entity.ts index e9149b1..9887e61 100644 --- a/backend/src/user/user.entity.ts +++ b/backend/src/user/user.entity.ts @@ -2,10 +2,10 @@ import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { - @PrimaryGeneratedColumn({ type: 'bigint' }) + @PrimaryGeneratedColumn() id: number; - @Column({ type: 'bigint' }) + @Column() githubId: number; @Column() diff --git a/frontend/public/index.html b/frontend/public/index.html index aa069f2..11b1d98 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -5,10 +5,7 @@ - + - React App + boostCam diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9d6662f..a8941eb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,22 +1,25 @@ -import React from 'react'; +import React, { Suspense } from 'react'; import { RecoilRoot } from 'recoil'; import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; -import LoginMain from './components/LoginPage/LoginMain'; -import Cam from './components/Cam/Cam'; -import BoostCamMain from './components/Main/BoostCamMain'; -import LoginCallback from './components/LoginPage/LoginCallback'; +import LoginMain from './components/Login/LoginMain'; +import CamMain from './components/Cam/CamMain'; +import LoginCallback from './components/Login/LoginCallback'; +import Main from './components/Main/Main'; +import Loading from './components/core/Loading'; function App(): JSX.Element { return ( - - } /> - } /> - } /> - } /> - + }> + + } /> + } /> + } /> + } /> + + ); diff --git a/frontend/src/assets/hmm.gif b/frontend/src/assets/hmm.gif new file mode 100644 index 0000000..8d31a4c Binary files /dev/null and b/frontend/src/assets/hmm.gif differ diff --git a/frontend/src/assets/loading.png b/frontend/src/assets/loading.png new file mode 100644 index 0000000..7373e63 Binary files /dev/null and b/frontend/src/assets/loading.png differ diff --git a/frontend/src/assets/loading.svg b/frontend/src/assets/loading.svg new file mode 100644 index 0000000..1e9d52a --- /dev/null +++ b/frontend/src/assets/loading.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/atoms/socket.ts b/frontend/src/atoms/socket.ts deleted file mode 100644 index 8a6fa87..0000000 --- a/frontend/src/atoms/socket.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atom } from 'recoil'; -import { io } from 'socket.io-client'; - -const socketState = atom({ - key: 'socket', - default: io(), -}); - -export default socketState; diff --git a/frontend/src/atoms/user.ts b/frontend/src/atoms/user.ts index 1796ff1..6c96a3a 100644 --- a/frontend/src/atoms/user.ts +++ b/frontend/src/atoms/user.ts @@ -1,9 +1,14 @@ -import { atom } from 'recoil'; -import User from '../types/user'; +import { selector } from 'recoil'; -const userState = atom({ +import { User } from '../types/user'; +import { fetchData } from '../utils/fetchMethods'; + +const userState = selector({ key: 'user', - default: null, + get: async () => { + const { data } = await fetchData('GET', '/api/user'); + return data; + }, }); export default userState; diff --git a/frontend/src/components/Cam/Cam.tsx b/frontend/src/components/Cam/CamMain.tsx similarity index 73% rename from frontend/src/components/Cam/Cam.tsx rename to frontend/src/components/Cam/CamMain.tsx index 48e42a4..f70dbf8 100644 --- a/frontend/src/components/Cam/Cam.tsx +++ b/frontend/src/components/Cam/CamMain.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import styled from 'styled-components'; +import { useRecoilValue } from 'recoil'; import ButtonBar from './Menu/ButtonBar'; import ChattingTab from './Menu/ChattingTab'; @@ -15,50 +16,33 @@ import CamNotFoundPage from './Page/CamNotFoundPage'; import CamLoadingPage from './Page/CamLoadingPage'; import CamNotAvailablePage from './Page/CamNotAvailablePage'; import CamErrorPage from './Page/CamErrorPage'; +import userState from '../../atoms/user'; +import { fetchData } from '../../utils/fetchMethods'; +import { flex } from '../../utils/styledComponentFunc'; const Container = styled.div` + background-color: black; width: 100vw; height: 100vh; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: center; - overflow-x: hidden; - overflow-y: hidden; - background-color: black; -`; - -const UpperTab = styled.div` - margin-top: 5px; - width: 98vw; - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; + ${flex('row', 'space-between')}; position: relative; `; -function Cam(): JSX.Element { +function CamMain(): JSX.Element { + const user = useRecoilValue(userState); const [userInfo, setUserInfo] = useState({ roomId: null, nickname: null }); const [statusCode, setStatusCode] = useState(0); const camRef = useRef(null); const checkRoomExist = async (roomId: string) => { - const response = await fetch(`/api/cam/${roomId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - - const json = await response.json(); - - setStatusCode(json.statusCode); + const { statusCode: newStatusCode } = await fetchData('GET', `/api/cam/${roomId}`); + setStatusCode(newStatusCode); }; useEffect(() => { const roomId = new URLSearchParams(new URL(window.location.href).search).get('roomid'); + if (roomId) { checkRoomExist(roomId); } @@ -66,6 +50,13 @@ function Cam(): JSX.Element { setUserInfo((prev) => ({ ...prev, roomId })); }, []); + useEffect(() => { + if (!user) { + return; + } + setUserInfo((prev) => ({ ...prev, nickname: user.nickname })); + }, [user]); + switch (statusCode) { case 0: return ; @@ -83,11 +74,9 @@ function Cam(): JSX.Element { - - - - - + + + @@ -100,4 +89,4 @@ function Cam(): JSX.Element { } } -export default Cam; +export default CamMain; diff --git a/frontend/src/components/Cam/CamStore.tsx b/frontend/src/components/Cam/CamStore.tsx index 5b5261f..6145fc8 100644 --- a/frontend/src/components/Cam/CamStore.tsx +++ b/frontend/src/components/Cam/CamStore.tsx @@ -1,5 +1,5 @@ -import React, { createContext } from 'react'; -import { io } from 'socket.io-client'; +import React, { createContext, useEffect, useRef } from 'react'; +import { io, Socket } from 'socket.io-client'; import useUserMedia from '../../hooks/useUserMedia'; import { UserInfo } from '../../types/cam'; @@ -11,21 +11,43 @@ type CamStoreProps = { }; export const CamStoreContext = createContext(null); -const socket = io(); function CamStore(props: CamStoreProps): JSX.Element { const { children, userInfo, setUserInfo } = props; const currentURL = new URL(window.location.href); const roomId = currentURL.searchParams.get('roomid'); + + const socketRef = useRef(); + + if (!socketRef.current) { + const socket = io('/cam', { + withCredentials: true, + forceNew: true, + transports: ['polling'], + }); + + socketRef.current = socket; + } + const { localStatus, localStream, setLocalStatus, screenList } = useUserMedia({ - socket, + socket: socketRef.current, roomId, userInfo, }); + useEffect(() => { + const socketDisconnect = () => { + socketRef?.current?.disconnect(); + }; + window.addEventListener('popstate', socketDisconnect); + return () => { + window.removeEventListener('popstate', socketDisconnect); + }; + }, []); + return ( {children} diff --git a/frontend/src/components/Cam/Menu/ButtonBar.tsx b/frontend/src/components/Cam/Menu/ButtonBar.tsx index 494d945..23d1298 100644 --- a/frontend/src/components/Cam/Menu/ButtonBar.tsx +++ b/frontend/src/components/Cam/Menu/ButtonBar.tsx @@ -1,13 +1,14 @@ import React, { RefObject, useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; -import { ButtonBarIcons } from '../../../utils/SvgIcons'; +import { ButtonBarIcons } from '../../../utils/svgIcons'; import { CamStoreContext } from '../CamStore'; import type { Status } from '../../../types/cam'; import { ToggleStoreContext } from '../ToggleStore'; import { STTStoreContext } from '../STT/STTStore'; import { SharedScreenStoreContext } from '../SharedScreen/SharedScreenStore'; import NicknameModal from './NicknameModal'; +import { flex } from '../../../utils/styledComponentFunc'; const { MicIcon, @@ -24,34 +25,27 @@ const { } = ButtonBarIcons; const Container = styled.div<{ isMouseOnCamPage: boolean }>` - width: 98vw; + width: 100%; height: 8vh; - margin-top: 5px; - - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; + box-sizing: border-box; + padding: 10px; + ${flex('row', 'space-between', 'center')}; border-radius: 10px; transition: bottom 0.5s ease; position: absolute; bottom: ${(props) => (props.isMouseOnCamPage ? '0' : '-8vh')}; + background-color: black; `; const ButtonContainer = styled.div` - display: flex; - flex-direction: row; - align-items: center; + ${flex('row', 'initial', 'center')}; `; const Button = styled.div<{ color?: string }>` width: 9vw; height: 7vh; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: center; + ${flex('column', 'flex-start', 'center')}; color: ${(props) => (props.color ? props.color : '#bbbbbb')}; border-radius: 10px; @@ -77,7 +71,7 @@ function ButtonBar(props: ButtonBarProps): JSX.Element { const [isMouseOnCamPage, setMouseOnCamPage] = useState(true); const [isActiveNicknameModal, setIsActiveNicknameModal] = useState(false); - const { localStream, setLocalStatus, localStatus, setUserInfo } = useContext(CamStoreContext); + const { localStream, setLocalStatus, localStatus, setUserInfo, socket } = useContext(CamStoreContext); const { handleChattingTabActive } = useContext(ToggleStoreContext); const { toggleSTTActive, isSTTActive } = useContext(STTStoreContext); @@ -110,6 +104,7 @@ function ButtonBar(props: ButtonBarProps): JSX.Element { }; const handleExit = () => { + socket.disconnect(); window.history.back(); }; diff --git a/frontend/src/components/Cam/Menu/ChattingTab.tsx b/frontend/src/components/Cam/Menu/ChattingTab.tsx index edcb776..2f8b9f5 100644 --- a/frontend/src/components/Cam/Menu/ChattingTab.tsx +++ b/frontend/src/components/Cam/Menu/ChattingTab.tsx @@ -4,20 +4,20 @@ import styled from 'styled-components'; import STTScreen from '../STT/STTScreen'; import { ToggleStoreContext } from '../ToggleStore'; import { CamStoreContext } from '../CamStore'; +import getCurrentDate from '../../../utils/getCurrentDate'; +import { CamMessageInfo, CamRoomInfo } from '../../../types/cam'; +import { customScroll, flex } from '../../../utils/styledComponentFunc'; const Container = styled.div<{ isActive: boolean; isMouseOnCamPage: boolean }>` height: 90vh; background-color: #ffffff; - display: flex; + transition: right 0.5s, opacity 0.5s; position: absolute; width: 27vw; right: ${(props) => (props.isActive ? '0' : '-30vw')}; opacity: ${(props) => (props.isActive ? '1' : '0')}; - - flex-direction: column; - justify-content: space-around; - align-items: center; + ${flex('column', 'space-around', 'center')}; `; const ChatLogs = styled.div` @@ -26,38 +26,20 @@ const ChatLogs = styled.div` background-color: #ffffff; overflow-y: auto; - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: center; - - &::-webkit-scrollbar { - width: 10px; - } - &::-webkit-scrollbar-thumb { - background-color: #999999; - border-radius: 10px; - } - &::-webkit-scrollbar-track { - background-color: #cccccc; - border-radius: 10px; - } + ${flex('column', 'flex-start', 'center')}; + ${customScroll()}; `; const ChatContainer = styled.div<{ isMe: boolean }>` width: 90%; - display: flex; - flex-direction: column; - justify-content: flex-start; + ${flex('column', 'flex-start')}; align-items: ${(props) => (props.isMe ? 'flex-end' : 'flex-start')}; `; const ChatTop = styled.div<{ isMe: boolean }>` width: 100%; - display: flex; - flex-direction: row; + ${flex('row', 'initial', 'center')}; justify-content: ${(props) => (props.isMe ? 'end' : 'start')}; - align-items: center; margin-top: 5px; `; @@ -94,21 +76,7 @@ const ChatTextarea = styled.textarea` font-size: 16px; padding: 10px 8px; box-sizing: border-box; - - &::-webkit-scrollbar { - width: 10px; - padding: 0px 8px; - } - &::-webkit-scrollbar-thumb { - background-color: #999999; - border-radius: 10px; - padding: 0px 8px; - } - &::-webkit-scrollbar-track { - background-color: #cccccc; - border-radius: 10px; - padding: 0px 8px; - } + ${customScroll()}; `; const TextContainer = styled.div` @@ -119,42 +87,11 @@ const TextContainer = styled.div` width: -webkit-fill-available; `; -type CurrentDate = { - year: number; - month: number; - date: number; - hour: number; - minutes: number; -}; - -type MessageInfo = { - msg: string; - room: string | null; - user: string; - date: CurrentDate; -}; - -type RoomInfo = { - socketId: string; - userNickname: string; -}; - -const getCurrentDate = (): CurrentDate => { - const today: Date = new Date(); - return { - year: today.getFullYear(), - month: today.getMonth() + 1, - date: today.getDate(), - hour: today.getHours(), - minutes: today.getMinutes(), - }; -}; - function ChattingTab(): JSX.Element { const { isChattingTabActive, isMouseOnCamPage } = useContext(ToggleStoreContext); const { userInfo, setLocalStatus, socket } = useContext(CamStoreContext); - const [chatLogs, setChatLogs] = useState([]); - const [nicknameList, setNicknameList] = useState([ + const [chatLogs, setChatLogs] = useState([]); + const [nicknameList, setNicknameList] = useState([ { socketId: socket.id, userNickname: userInfo.nickname, @@ -164,10 +101,11 @@ function ChattingTab(): JSX.Element { const chatLogsRef = useRef(null); const sendMessage = (msg: string) => { - const currentDate = getCurrentDate(); - const msgInfo: MessageInfo = { msg, room, user: socket.id, date: currentDate }; + const today = new Date(); + const currentDate = getCurrentDate(today); + const msgInfo: CamMessageInfo = { msg, room, user: socket.id, date: currentDate }; - socket.emit('sendMessage', msgInfo); + socket.emit('sendCamMessage', msgInfo); setChatLogs((logs) => [...logs, msgInfo]); }; @@ -186,11 +124,14 @@ function ChattingTab(): JSX.Element { }; useEffect(() => { - socket.on('receiveMessage', ({ payload, nicknameInfo }: { payload: MessageInfo; nicknameInfo: RoomInfo[] }) => { - setChatLogs((logs) => [...logs, payload]); - setNicknameList(nicknameInfo); - }); - socket.on('getNicknameList', (nicknameInfo: RoomInfo[]) => { + socket.on( + 'receiveCamMessage', + ({ payload, nicknameInfo }: { payload: CamMessageInfo; nicknameInfo: CamRoomInfo[] }) => { + setChatLogs((logs) => [...logs, payload]); + setNicknameList(nicknameInfo); + }, + ); + socket.on('getNicknameList', (nicknameInfo: CamRoomInfo[]) => { setNicknameList(nicknameInfo); }); }, []); @@ -201,7 +142,7 @@ function ChattingTab(): JSX.Element { } }); - const currentChatLogs = chatLogs.map((data: MessageInfo, index: number): JSX.Element => { + const currentChatLogs = chatLogs.map((data: CamMessageInfo, index: number): JSX.Element => { const { msg, date, user } = data; const time = `${date.hour}:${date.minutes < 10 ? `0${date.minutes}` : date.minutes}`; const isMe = user === socket.id; diff --git a/frontend/src/components/Cam/Menu/NicknameModal.tsx b/frontend/src/components/Cam/Menu/NicknameModal.tsx index 1b547c1..598e4f3 100644 --- a/frontend/src/components/Cam/Menu/NicknameModal.tsx +++ b/frontend/src/components/Cam/Menu/NicknameModal.tsx @@ -2,6 +2,7 @@ import React, { useContext } from 'react'; import styled from 'styled-components'; import { UserInfo } from '../../../types/cam'; +import { flex } from '../../../utils/styledComponentFunc'; import { CamStoreContext } from '../CamStore'; const Container = styled.div` @@ -10,10 +11,7 @@ const Container = styled.div` height: 100vh; left: 0px; right: 0px; - - display: flex; - justify-content: space-around; - align-items: center; + ${flex('column', 'space-around', 'center')} z-index: 2; `; @@ -30,10 +28,7 @@ const ModalBox = styled.div` width: 30%; height: 20%; background-color: white; - display: flex; - flex-direction: column; - justify-content: space-around; - align-items: center; + ${flex('column', 'space-around', 'center')} padding: 20px; border-radius: 20px; box-shadow: 0px 5px 22px -2px #000000; @@ -42,10 +37,7 @@ const ModalBox = styled.div` const Form = styled.form` border-radius: 20px; - - display: flex; - justify-content: space-around; - align-items: center; + ${flex('row', 'space-around', 'center')} `; const Input = styled.input` diff --git a/frontend/src/components/Cam/Menu/UserListTab.tsx b/frontend/src/components/Cam/Menu/UserListTab.tsx index bf29742..95e5108 100644 --- a/frontend/src/components/Cam/Menu/UserListTab.tsx +++ b/frontend/src/components/Cam/Menu/UserListTab.tsx @@ -7,6 +7,7 @@ import LocalUserScreen from '../Screen/LocalUserScreen'; import Draggable from '../../core/Draggable'; import type { Screen } from '../../../types/cam'; import { ToggleStoreContext } from '../ToggleStore'; +import { customScroll } from '../../../utils/styledComponentFunc'; const Container = styled.div<{ isActive: boolean }>` position: absolute; @@ -17,17 +18,7 @@ const Container = styled.div<{ isActive: boolean }>` display: ${(props) => (props.isActive ? 'block' : 'none')}; overflow-y: auto; - &::-webkit-scrollbar { - width: 10px; - } - &::-webkit-scrollbar-thumb { - background-color: #999999; - border-radius: 10px; - } - &::-webkit-scrollbar-track { - background-color: #cccccc; - border-radius: 10px; - } + ${customScroll()}; `; function UserListTab(): JSX.Element { @@ -43,9 +34,9 @@ function UserListTab(): JSX.Element { isActive={isUserListTabActive} > - + {screenList.map((screen: Screen) => ( - + ))} diff --git a/frontend/src/components/Cam/Page/CamDefaultPage.tsx b/frontend/src/components/Cam/Page/CamDefaultPage.tsx index e95603a..2e0d188 100644 --- a/frontend/src/components/Cam/Page/CamDefaultPage.tsx +++ b/frontend/src/components/Cam/Page/CamDefaultPage.tsx @@ -1,5 +1,6 @@ import React from 'react'; import styled from 'styled-components'; +import { flex } from '../../../utils/styledComponentFunc'; type CamDefaultPageProps = { backgroundSrc: string; @@ -12,10 +13,7 @@ const Container = styled.div` height: 100vh; left: 0px; right: 0px; - - display: flex; - justify-content: center; - align-items: center; + ${flex('row', 'center', 'center')} background-color: white; `; diff --git a/frontend/src/components/Cam/Page/CamErrorPage.tsx b/frontend/src/components/Cam/Page/CamErrorPage.tsx index 60dbc89..50f96ea 100644 --- a/frontend/src/components/Cam/Page/CamErrorPage.tsx +++ b/frontend/src/components/Cam/Page/CamErrorPage.tsx @@ -1,14 +1,13 @@ import React from 'react'; import styled from 'styled-components'; +import { flex } from '../../../utils/styledComponentFunc'; import CamDefaultPage from './CamDefaultPage'; const Title = styled.div` background-color: rgba(0, 0, 0, 0.7); width: 100%; height: 20%; - display: flex; - justify-content: center; - align-items: center; + ${flex('row', 'center', 'center')}; color: white; font-size: 44px; `; diff --git a/frontend/src/components/Cam/Page/CamLoadingPage.tsx b/frontend/src/components/Cam/Page/CamLoadingPage.tsx index 820e554..b835ef9 100644 --- a/frontend/src/components/Cam/Page/CamLoadingPage.tsx +++ b/frontend/src/components/Cam/Page/CamLoadingPage.tsx @@ -1,14 +1,13 @@ import React from 'react'; import styled from 'styled-components'; +import { flex } from '../../../utils/styledComponentFunc'; import CamDefaultPage from './CamDefaultPage'; const Title = styled.div` background-color: rgba(0, 0, 0, 0.7); width: 100%; height: 20%; - display: flex; - justify-content: center; - align-items: center; + ${flex('row', 'center', 'center')}; color: white; font-size: 44px; `; diff --git a/frontend/src/components/Cam/Page/CamNickNameInputPage.tsx b/frontend/src/components/Cam/Page/CamNickNameInputPage.tsx index 6c2725f..35336e4 100644 --- a/frontend/src/components/Cam/Page/CamNickNameInputPage.tsx +++ b/frontend/src/components/Cam/Page/CamNickNameInputPage.tsx @@ -1,15 +1,14 @@ import React from 'react'; import styled from 'styled-components'; import { UserInfo } from '../../../types/cam'; +import { flex } from '../../../utils/styledComponentFunc'; import CamDefaultPage from './CamDefaultPage'; const Form = styled.form` background-color: rgba(0, 0, 0, 0.7); width: 100%; height: 20%; - display: flex; - justify-content: center; - align-items: center; + ${flex('row', 'center', 'center')}; color: white; font-size: 44px; `; diff --git a/frontend/src/components/Cam/Page/CamNotAvailablePage.tsx b/frontend/src/components/Cam/Page/CamNotAvailablePage.tsx index 21d919a..debadda 100644 --- a/frontend/src/components/Cam/Page/CamNotAvailablePage.tsx +++ b/frontend/src/components/Cam/Page/CamNotAvailablePage.tsx @@ -1,14 +1,13 @@ import React from 'react'; import styled from 'styled-components'; +import { flex } from '../../../utils/styledComponentFunc'; import CamDefaultPage from './CamDefaultPage'; const Title = styled.div` background-color: rgba(0, 0, 0, 0.7); width: 100%; height: 20%; - display: flex; - justify-content: center; - align-items: center; + ${flex('row', 'center', 'center')}; color: white; font-size: 44px; `; diff --git a/frontend/src/components/Cam/Page/CamNotFoundPage.tsx b/frontend/src/components/Cam/Page/CamNotFoundPage.tsx index 4632cc0..6652178 100644 --- a/frontend/src/components/Cam/Page/CamNotFoundPage.tsx +++ b/frontend/src/components/Cam/Page/CamNotFoundPage.tsx @@ -1,14 +1,13 @@ import React from 'react'; import styled from 'styled-components'; +import { flex } from '../../../utils/styledComponentFunc'; import CamDefaultPage from './CamDefaultPage'; const Title = styled.div` background-color: rgba(0, 0, 0, 0.7); width: 100%; height: 20%; - display: flex; - justify-content: center; - align-items: center; + ${flex('row', 'center', 'center')}; color: white; font-size: 44px; `; diff --git a/frontend/src/components/Cam/STT/STTScreen.tsx b/frontend/src/components/Cam/STT/STTScreen.tsx index 5821ea6..30b6940 100644 --- a/frontend/src/components/Cam/STT/STTScreen.tsx +++ b/frontend/src/components/Cam/STT/STTScreen.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useContext } from 'react'; import styled from 'styled-components'; import { Status } from '../../../types/cam'; +import { flex } from '../../../utils/styledComponentFunc'; import { STTStoreContext } from './STTStore'; const Container = styled.div` @@ -11,9 +12,7 @@ const Container = styled.div` height: 80px; border-bottom: 2px solid #999999; margin-top: 5px; - - display: flex; - flex-direction: column; + ${flex('column')} `; const Title = styled.div` diff --git a/frontend/src/components/Cam/Screen/DefaultScreen.tsx b/frontend/src/components/Cam/Screen/DefaultScreen.tsx index 089ea4f..7396c58 100644 --- a/frontend/src/components/Cam/Screen/DefaultScreen.tsx +++ b/frontend/src/components/Cam/Screen/DefaultScreen.tsx @@ -1,10 +1,10 @@ import styled from 'styled-components'; +import { flex } from '../../../utils/styledComponentFunc'; const Container = styled.div` - width: 100%; + width: 90%; max-height: 100%; - display: flex; - justify-content: center; + ${flex('row', 'center')}; `; const DefaultImg = styled.img` diff --git a/frontend/src/components/Cam/Screen/LocalUserScreen.tsx b/frontend/src/components/Cam/Screen/LocalUserScreen.tsx index eed3e9c..8839933 100644 --- a/frontend/src/components/Cam/Screen/LocalUserScreen.tsx +++ b/frontend/src/components/Cam/Screen/LocalUserScreen.tsx @@ -4,23 +4,28 @@ import styled from 'styled-components'; import DefaultScreen from './DefaultScreen'; import { CamStoreContext } from '../CamStore'; import StreamStatusIndicator from './StreamStatusIndicator'; +import { flex } from '../../../utils/styledComponentFunc'; -const Container = styled.div` +const Container = styled.div<{ numOfScreen: number }>` position: relative; - width: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - height: 90%; + width: calc(100% / ${(props) => Math.ceil(props.numOfScreen ** 0.5)}); + height: calc(100% / ${(props) => Math.floor((props.numOfScreen + 1) ** 0.5)}); + ${flex('column', 'center', 'center')} + aspect-ratio: 16/9; + overflow: hidden; `; const Video = styled.video` max-height: 100%; - width: 100%; + width: 90%; `; -function LocalUserScreen(): JSX.Element { +type LocalUserScreenProps = { + numOfScreen: number; +}; + +function LocalUserScreen(props: LocalUserScreenProps): JSX.Element { + const { numOfScreen } = props; const videoRef = useRef(null); const { localStream, localStatus, userInfo } = useContext(CamStoreContext); @@ -34,7 +39,7 @@ function LocalUserScreen(): JSX.Element { }); return ( - + {localStatus.stream && localStatus.video ? (