diff --git a/package-lock.json b/package-lock.json index b77a904..a8701a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,10 @@ "@nestjs/websockets": "^10.2.1", "agora-access-token": "^2.0.4", "aws-sdk": "^2.1410.0", + "axios": "^1.5.0", "cache-manager": "^5.2.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", "cross-env": "^7.0.3", "discord-webhook-node": "^1.1.8", "dotenv": "^16.3.1", @@ -3383,6 +3386,11 @@ "@types/superagent": "*" } }, + "node_modules/@types/validator": { + "version": "13.11.1", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.1.tgz", + "integrity": "sha512-d/MUkJYdOeKycmm75Arql4M5+UuXmf4cHdHKsyw1GcvnNgL6s77UkgSgJ8TE/rI5PYsnwYq5jkcWBLuN/MpQ1A==" + }, "node_modules/@types/yargs": { "version": "17.0.24", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.24.tgz", @@ -4020,10 +4028,9 @@ } }, "node_modules/axios": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", - "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", - "peer": true, + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.5.0.tgz", + "integrity": "sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==", "dependencies": { "follow-redirects": "^1.15.0", "form-data": "^4.0.0", @@ -4511,6 +4518,21 @@ "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", "dev": true }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==" + }, + "node_modules/class-validator": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.0.tgz", + "integrity": "sha512-ct3ltplN8I9fOwUd8GrP8UQixwff129BkEtuWDKL5W45cQuLd19xqmTLu5ge78YDm/fdje6FMt0hGOhl0lii3A==", + "dependencies": { + "@types/validator": "^13.7.10", + "libphonenumber-js": "^1.10.14", + "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", @@ -5841,7 +5863,6 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], - "peer": true, "engines": { "node": ">=4.0" }, @@ -7402,6 +7423,11 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.10.44", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.44.tgz", + "integrity": "sha512-svlRdNBI5WgBjRC20GrCfbFiclbF0Cx+sCcQob/C1r57nsoq0xg8r65QbTyVyweQIlB33P+Uahyho6EMYgcOyQ==" + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8316,8 +8342,7 @@ "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "peer": true + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, "node_modules/pump": { "version": "3.0.0", @@ -9780,6 +9805,14 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, + "node_modules/validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/package.json b/package.json index 139061b..ec7ccd3 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,10 @@ "@nestjs/websockets": "^10.2.1", "agora-access-token": "^2.0.4", "aws-sdk": "^2.1410.0", + "axios": "^1.5.0", "cache-manager": "^5.2.3", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", "cross-env": "^7.0.3", "discord-webhook-node": "^1.1.8", "dotenv": "^16.3.1", diff --git a/src/chatting/chatting.repository.ts b/src/chatting/chatting.repository.ts index 2434214..c5b83fe 100644 --- a/src/chatting/chatting.repository.ts +++ b/src/chatting/chatting.repository.ts @@ -51,7 +51,12 @@ export class ChattingRepository { } }*/ - async sendMessage(roomId: string, senderId: string, format: string, message) { + async sendMessage( + roomId: string, + senderId: string, + format: string, + message?: any, + ) { const chatting = await this.chattingModel.get({ id: roomId }); chatting.messages.push({ sender: senderId, @@ -73,10 +78,24 @@ export class ChattingRepository { }); } + async getIdByQuestionAndTeacher(questionId: string, teacherId: string) { + const result = await this.chattingModel + .scan({ questionId, teacherId }) + .exec(); + if (result.length > 0) { + return result[0].id; + } + throw new Error('채팅방을 찾을 수 없습니다.'); + } + async getChatRoomsInfo(roomIds: ChattingKey[]) { return await this.chattingModel.batchGet(roomIds); } + /* + * 채팅 객체를 생성 합니다 + * 이 함수는 user.participantingChattingRooms 에 추가 해주지 않음 + */ async makeChatRoom(teacherId: string, studentId: string, questionId: string) { const chattingRoomId = uuid(); const chatting: Chatting = { diff --git a/src/chatting/chatting.service.ts b/src/chatting/chatting.service.ts index e76bc6b..7847e81 100644 --- a/src/chatting/chatting.service.ts +++ b/src/chatting/chatting.service.ts @@ -58,10 +58,23 @@ export class ChattingService { roomInfo.questionId, ); console.log(questionInfo); - const { status, isSelect } = questionInfo; + const { status, isSelect, selectedTeacherId } = questionInfo; const { schoolSubject, schoolLevel, description } = questionInfo.problem; + let chatState: 'pending' | 'reserved' | 'refused' = 'pending'; + + if (status == 'pending') { + chatState = 'pending'; + } else if (status == 'reserved') { + if (userRole == 'student' || selectedTeacherId == userId) { + chatState = 'reserved'; + } else { + // 선생님이 api 부른 경우에 거절 당한 경우. + chatState = 'refused'; + } + } + const item = { roomImage: undefined, id: roomInfo.id, @@ -71,7 +84,7 @@ export class ChattingService { return { body: JSON.parse(body), isMyMsg: isMyMsg, ...rest }; }), opponentId: undefined, - questionState: status, + questionState: chatState, problemImages: questionInfo.problem.mainImage, isSelect: isSelect, isTeacherRoom: true, @@ -79,7 +92,6 @@ export class ChattingService { schoolSubject: schoolSubject, schoolLevel: schoolLevel, title: undefined, - status: status, description: description, }; @@ -107,13 +119,18 @@ export class ChattingService { roomInfosWithQuestion.forEach((roomInfo) => { if (roomInfo.isSelect) { - if (roomInfo.status === 'pending') { + //지정 질문 + if (roomInfo.questionState === 'pending') { result.selectedProposed.push(roomInfo); - } else if (roomInfo.status === 'reserved') { + } else if (roomInfo.questionState === 'reserved') { result.selectedReserved.push(roomInfo); } } else { - if (roomInfo.status === 'pending') { + //일반 질문 + if ( + roomInfo.questionState === 'pending' || + roomInfo.questionState === 'refused' + ) { if (userRole == 'student') { //grouping by questionId if (normalProposedGrouping[roomInfo.questionId]) { diff --git a/src/main.ts b/src/main.ts index 56a463a..d673d51 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { AppModule } from './app.module'; import { configSwagger } from './config.swagger'; +import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import * as dotenv from 'dotenv'; import { json, urlencoded } from 'express'; @@ -9,7 +10,7 @@ async function bootstrap() { app.enableCors(); app.use(json({ limit: '50mb' })); app.use(urlencoded({ limit: '50mb', extended: false })); - + app.useGlobalPipes(new ValidationPipe({ transform: true })); dotenv.config(); configSwagger(app); diff --git a/src/offer/offer.service.ts b/src/offer/offer.service.ts index f476c28..34107c2 100644 --- a/src/offer/offer.service.ts +++ b/src/offer/offer.service.ts @@ -102,7 +102,28 @@ export class OfferService { chatting.teacherId, ); - return new Success('선생님 선택이 완료되었습니다.', tutoring); + const question = await this.questionRepository.getInfo(questionId); + + const offerTeacherIds = question.offerTeachers; + + for (const offerTeacherId of offerTeacherIds) { + if (offerTeacherId != chatting.teacherId) { + const teacherChatId = + await this.chattingRepository.getIdByQuestionAndTeacher( + questionId, + offerTeacherId, + ); + //TODO: redis pub/sub으로 변경 + await this.chattingRepository.sendMessage( + teacherChatId, + userId, + 'text', + '죄송합니다.\n다른 선생님과 수업을 진행하기로 했습니다.', + ); + } + } + + return new Success('선생님 선택이 완료되었습니다.'); } catch (error) { return new Fail(error.message); } diff --git a/src/question/question.repository.ts b/src/question/question.repository.ts index 5bbe8b5..80a58d9 100644 --- a/src/question/question.repository.ts +++ b/src/question/question.repository.ts @@ -64,6 +64,7 @@ export class QuestionRepository { async createSelectedQuestion( questionId: string, userId: string, + selectedTeacherId: string, createQuestionDto: CreateSelectedQuestionDto, problemImages: string[], ): Promise { @@ -83,7 +84,7 @@ export class QuestionRepository { schoolLevel: createQuestionDto.schoolLevel, schoolSubject: createQuestionDto.schoolSubject, }, - selectedTeacherId: '', + selectedTeacherId: selectedTeacherId, status: 'pending', studentId: userId, offerTeachers: [], @@ -123,6 +124,20 @@ export class QuestionRepository { return await this.questionModel.get({ id: questionId }); } + async changeStatus(questionId: string, status: string) { + return await this.questionModel.update( + { id: questionId }, + { status: status }, + ); + } + + async setSeletedTeacherId(questionId: string, teacherId: string) { + return await this.questionModel.update( + { id: questionId }, + { selectedTeacherId: teacherId }, + ); + } + async delete(userId: string, questionId: string) { const user: User = await this.userRepository.get(userId); if (user.role === 'teacher') { diff --git a/src/question/question.service.ts b/src/question/question.service.ts index 086e89b..02f2206 100644 --- a/src/question/question.service.ts +++ b/src/question/question.service.ts @@ -62,6 +62,7 @@ export class QuestionService { await this.questionRepository.createSelectedQuestion( questionId, userId, + teacherId, createQuestionDto, problemImages, ); diff --git a/src/tutoring/descriptions/tutoring.operation.ts b/src/tutoring/descriptions/tutoring.operation.ts index 30d887e..be73664 100644 --- a/src/tutoring/descriptions/tutoring.operation.ts +++ b/src/tutoring/descriptions/tutoring.operation.ts @@ -7,4 +7,8 @@ export const TutoringOperation = { summary: '과외 정보 조희', description: '특정 과외의 정보를 조회합니다', }, + appoint: { + summary: '과외 시간 확정', + description: '특정 과외의 시작 종료 시간을 확정합니다', + }, }; diff --git a/src/tutoring/dto/create-tutoring.dto.ts b/src/tutoring/dto/create-tutoring.dto.ts index 0e4f356..e2d86d6 100644 --- a/src/tutoring/dto/create-tutoring.dto.ts +++ b/src/tutoring/dto/create-tutoring.dto.ts @@ -1,4 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsDate } from 'class-validator'; export class CreateTutoringDto { @ApiProperty({ @@ -19,3 +21,21 @@ export class CreateTutoringDto { }) teacherId: string; } + +export class AppointTutoringDto { + @Type(() => Date) + @IsDate() + @ApiProperty({ + description: '수업 시작 날짜,시간', + example: '2023-11-30T18:00:00+09:00', + }) + startTime: Date; + + @Type(() => Date) + @IsDate() + @ApiProperty({ + description: '수업 종료 날짜,시간', + example: '2023-11-30T19:00:00+09:00', + }) + endTime: Date; +} diff --git a/src/tutoring/entities/tutoring.interface.ts b/src/tutoring/entities/tutoring.interface.ts index 06b1301..0ca33e2 100644 --- a/src/tutoring/entities/tutoring.interface.ts +++ b/src/tutoring/entities/tutoring.interface.ts @@ -3,7 +3,7 @@ export interface TutoringKey { } export interface Tutoring extends TutoringKey { - requestId?: string; + questionId: string; studentId: string; teacherId: string; status: string; @@ -16,4 +16,6 @@ export interface Tutoring extends TutoringKey { teacherRTCToken?: string; studentRTCToken?: string; RTCAppId?: string; + reservedStart?: Date; + reservedEnd?: Date; } diff --git a/src/tutoring/entities/tutoring.schema.ts b/src/tutoring/entities/tutoring.schema.ts index 4167caa..ecc11ff 100644 --- a/src/tutoring/entities/tutoring.schema.ts +++ b/src/tutoring/entities/tutoring.schema.ts @@ -5,7 +5,7 @@ export const TutoringSchema = new Schema({ type: String, hashKey: true, }, - requestId: { + questionId: { type: String, }, studentId: { @@ -44,4 +44,10 @@ export const TutoringSchema = new Schema({ RTCAppId: { type: String, }, + reservedStart: { + type: Date, + }, + reservedEnd: { + type: Date, + }, }); diff --git a/src/tutoring/tutoring.controller.ts b/src/tutoring/tutoring.controller.ts index 3559a69..cf097c7 100644 --- a/src/tutoring/tutoring.controller.ts +++ b/src/tutoring/tutoring.controller.ts @@ -1,6 +1,8 @@ +import { AccessToken } from '../auth/entities/auth.entity'; import { TutoringOperation } from './descriptions/tutoring.operation'; +import { AppointTutoringDto } from './dto/create-tutoring.dto'; import { TutoringService } from './tutoring.service'; -import { Controller, Get, Param } from '@nestjs/common'; +import { Body, Controller, Get, Headers, Param, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; @ApiTags('Tutoring') @@ -18,7 +20,31 @@ export class TutoringController { @ApiBearerAuth('Authorization') @ApiOperation(TutoringOperation.info) @Get('info/:questionId') - info(@Param('questionId') questionId: string) { - return this.tutoringService.info(questionId); + info(@Param('questionId') questionId: string, @Headers() headers: Headers) { + return this.tutoringService.info(questionId, AccessToken.userId(headers)); + } + + @ApiTags('Teacher') + @ApiBearerAuth('Authorization') + @Get('start/:questionId') + start(@Param('questionId') questionId: string, @Headers() headers: Headers) { + return this.tutoringService.startTutoring( + AccessToken.userId(headers), + questionId, + ); + } + + @ApiBearerAuth('Authorization') + @ApiOperation(TutoringOperation.appoint) + @Post('appoint/:questionId') + appoint( + @Param('questionId') questionId: string, + @Body() appointTutoringDto: AppointTutoringDto, + ) { + return this.tutoringService.reserveTutoring( + questionId, + appointTutoringDto.startTime, + appointTutoringDto.endTime, + ); } } diff --git a/src/tutoring/tutoring.repository.ts b/src/tutoring/tutoring.repository.ts index dc76c05..1adadc5 100644 --- a/src/tutoring/tutoring.repository.ts +++ b/src/tutoring/tutoring.repository.ts @@ -12,11 +12,7 @@ export class TutoringRepository { private readonly agoraService: AgoraService, ) {} - async create( - questionId: string, - studentId: string, - teacherId: string, - ): Promise { + async create(questionId: string, studentId: string, teacherId: string) { const tutoringId = uuid(); const { whiteBoardAppId, whiteBoardUUID, whiteBoardToken }: WhiteBoardData = @@ -30,15 +26,13 @@ export class TutoringRepository { throw new Error('화이트보드 토큰을 생성할 수 없습니다'); } - const tutoring = { + const tutoringInfo = { id: tutoringId, questionId, studentId, teacherId, - status: 'matched', + status: 'reserved', matchedAt: new Date().toISOString(), - startedAt: '', - endedAt: '', whiteBoardAppId, whiteBoardUUID, whiteBoardToken, @@ -46,8 +40,37 @@ export class TutoringRepository { studentRTCToken: studentToken, RTCAppId: process.env.AGORA_RTC_APP_ID, }; - await this.tutoringModel.create(tutoring); - return tutoring; + return await this.tutoringModel.create(tutoringInfo); + } + + async reserveTutoring(tutoringId: string, startTime: Date, endTime: Date) { + const tutoring = await this.tutoringModel.get({ id: tutoringId }); + if (tutoring === undefined) { + throw new Error('숏과외를 찾을 수 없습니다.'); + } + if (tutoring.reservedStart != undefined) { + throw new Error('이미 예약된 과외입니다.'); + } + + return await this.tutoringModel.update( + { id: tutoringId }, + { reservedStart: startTime, reservedEnd: endTime }, + ); + } + + async startTutoring(tutoringId: string) { + const tutoring = await this.tutoringModel.get({ id: tutoringId }); + if (tutoring === undefined) { + throw new Error('숏과외를 찾을 수 없습니다.'); + } + if (tutoring.startedAt != undefined) { + throw new Error('이미 시작된 과외입니다.'); + } + + return await this.tutoringModel.update( + { id: tutoringId }, + { startedAt: new Date().toISOString(), status: 'going' }, + ); } async get(tutoringId: string): Promise { diff --git a/src/tutoring/tutoring.service.ts b/src/tutoring/tutoring.service.ts index f5e136d..b4d0313 100644 --- a/src/tutoring/tutoring.service.ts +++ b/src/tutoring/tutoring.service.ts @@ -1,6 +1,7 @@ import { AgoraService } from '../agora/agora.service'; import { QuestionRepository } from '../question/question.repository'; import { Fail, Success } from '../response'; +import { UserRepository } from '../user/user.repository'; import { TutoringRepository } from './tutoring.repository'; import { Injectable } from '@nestjs/common'; @@ -10,29 +11,83 @@ export class TutoringService { private readonly tutoringRepository: TutoringRepository, private readonly questionRepository: QuestionRepository, private readonly agoraService: AgoraService, + private readonly userRepository: UserRepository, ) {} async finish(tutoringId: string) { - //TODO: 과외에 참여한 사람이 맞는지 확인해야할까? try { const tutoring = await this.tutoringRepository.finishTutoring(tutoringId); const { whiteBoardUUID } = tutoring; await this.agoraService.disableWhiteBoardChannel(whiteBoardUUID); + await this.questionRepository.changeStatus( + tutoring.questionId, + 'finished', + ); + return new Success('과외가 종료되었습니다.', { tutoringId }); } catch (error) { return new Fail(error.message); } } - async info(questionId: string) { + async reserveTutoring(questionId: string, startTime: Date, endTime: Date) { + try { + const question = await this.questionRepository.getInfo(questionId); + + if (question.tutoringId != null) + return new Fail('이미 과외가 확정되었습니다.'); + + const tutoring = await this.tutoringRepository.create( + questionId, + question.studentId, + question.selectedTeacherId, + ); + + const result = await this.tutoringRepository.reserveTutoring( + tutoring.id, + startTime, + endTime, + ); + + await this.questionRepository.changeStatus(questionId, 'reserved'); + + return new Success('수업 시간이 확정되었습니다.', { result }); + } catch (error) { + return new Fail(error.message); + } + } + + async info(questionId: string, userId: string) { try { const question = await this.questionRepository.getInfo(questionId); + if (question.tutoringId == null) return new Fail('과외 정보가 없습니다.'); + const tutoring = await this.tutoringRepository.get(question.tutoringId); + + const userInfo = await this.userRepository.get(userId); + + if (userInfo.role == 'student') { + if (tutoring.status != 'going') { + return new Fail('수업 시작 전입니다.'); + } + } return new Success('과외 정보를 가져왔습니다.', { tutoring }); } catch (error) { return new Fail(error.message); } } + + async startTutoring(teacherId: string, tutoringId: string) { + try { + const tutoring = await this.tutoringRepository.get(tutoringId); + if (tutoring.teacherId != teacherId) { + return new Fail('해당 과외를 진행할 수 없습니다.'); + } + return await this.tutoringRepository.startTutoring(tutoringId); + } catch (error) { + return new Fail('과외 시작에 실패했습니다.'); + } + } }