diff --git a/src/course-review/course-review.controller.ts b/src/course-review/course-review.controller.ts index afada498..acd7e67d 100644 --- a/src/course-review/course-review.controller.ts +++ b/src/course-review/course-review.controller.ts @@ -23,6 +23,10 @@ import { TransactionInterceptor } from 'src/common/interceptors/transaction.inte import { TransactionManager } from 'src/decorators/manager.decorator'; import { EntityManager } from 'typeorm'; import { CourseReviewDocs } from 'src/decorators/docs/course-review.decorator'; +import { SearchCourseReviewsWithKeywordRequest } from './dto/search-course-reviews-with-keyword-request.dto'; +import { PaginatedCourseReviewsDto } from './dto/paginated-course-reviews.dto'; +import { GetCoursesWithCourseReviewsRequestDto } from './dto/get-courses-with-course-reviews-request.dto'; +import { GetCoursesWithCourseReviewsResponseDto } from './dto/get-courses-with-course-reviews-response.dto'; @ApiTags('course-review') @Controller('course-review') @@ -75,6 +79,27 @@ export class CourseReviewController { ); } + // 강의평 조회를 위한 New 검색 + @Get('search') + async getCourseReviewsWithKeyword( + @Query() + searchCourseReviewsWithKeywordRequest: SearchCourseReviewsWithKeywordRequest, + ): Promise { + return await this.courseReviewService.getCourseReviewsWithKeyword( + searchCourseReviewsWithKeywordRequest, + ); + } + + @Get('course') + async getCoursesWithCourseReviews( + @Query() + getCoursesWithCourseReviewsRequestDto: GetCoursesWithCourseReviewsRequestDto, + ): Promise { + return await this.courseReviewService.getCoursesWithCourseReviews( + getCoursesWithCourseReviewsRequestDto, + ); + } + // 강의평 조회 @Get() async getCourseReviews( diff --git a/src/course-review/course-review.module.ts b/src/course-review/course-review.module.ts index 7fcc148b..8fd29adf 100644 --- a/src/course-review/course-review.module.ts +++ b/src/course-review/course-review.module.ts @@ -7,6 +7,8 @@ import { AuthModule } from 'src/auth/auth.module'; import { UserModule } from 'src/user/user.module'; import { CourseModule } from 'src/course/course.module'; import { CourseReviewRecommendEntity } from 'src/entities/course-review-recommend.entity'; +import { RecentCourseReviewsStrategy } from './strategy/recent-course-reviews-strategy'; +import { GoodTeachingSkillReviewsStrategy } from './strategy/good-teaching-skill-reviews-strategy'; @Module({ imports: [ @@ -16,6 +18,18 @@ import { CourseReviewRecommendEntity } from 'src/entities/course-review-recommen CourseModule, ], controllers: [CourseReviewController], - providers: [CourseReviewService], + providers: [ + CourseReviewService, + RecentCourseReviewsStrategy, + GoodTeachingSkillReviewsStrategy, + { + provide: 'CourseReviewCriteriaStrategy', + useFactory: ( + recentCourseReviewsStrategy: RecentCourseReviewsStrategy, + goodTeachingSkillReviewsStrategy: GoodTeachingSkillReviewsStrategy, + ) => [recentCourseReviewsStrategy, goodTeachingSkillReviewsStrategy], + inject: [RecentCourseReviewsStrategy, GoodTeachingSkillReviewsStrategy], + }, + ], }) export class CourseReviewModule {} diff --git a/src/course-review/course-review.service.ts b/src/course-review/course-review.service.ts index f03fe92e..180a5d4c 100644 --- a/src/course-review/course-review.service.ts +++ b/src/course-review/course-review.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { AuthorizedUserDto } from 'src/auth/dto/authorized-user-dto'; import { CreateCourseReviewRequestDto } from './dto/create-course-review-request.dto'; import { CourseReviewResponseDto } from './dto/course-review-response.dto'; @@ -9,7 +9,7 @@ import { ReviewDto, } from './dto/get-course-reviews-response.dto'; import { GetCourseReviewSummaryResponseDto } from './dto/get-course-review-summary-response.dto'; -import { EntityManager, Repository } from 'typeorm'; +import { Brackets, EntityManager, Repository } from 'typeorm'; import { CourseReviewRecommendEntity } from 'src/entities/course-review-recommend.entity'; import { CourseReviewEntity } from 'src/entities/course-review.entity'; import { CourseReviewsFilterDto } from './dto/course-reviews-filter.dto'; @@ -17,7 +17,14 @@ import { CourseService } from 'src/course/course.service'; import { InjectRepository } from '@nestjs/typeorm'; import { PointService } from 'src/user/point.service'; import { throwKukeyException } from 'src/utils/exception.util'; - +import { SearchCourseReviewsWithKeywordRequest } from './dto/search-course-reviews-with-keyword-request.dto'; +import { SearchCourseReviewsWithKeywordResponse } from './dto/search-course-reviews-with-keyword-response.dto'; +import { PaginatedCourseReviewsDto } from './dto/paginated-course-reviews.dto'; +import { CourseEntity } from 'src/entities/course.entity'; +import { CourseReviewCriteriaStrategy } from './strategy/course-review-criteria-strategy'; +import { GetCoursesWithCourseReviewsRequestDto } from './dto/get-courses-with-course-reviews-request.dto'; +import { CourseReviewCriteria } from 'src/enums/course-review-criteria.enum'; +import { GetCoursesWithCourseReviewsResponseDto } from './dto/get-courses-with-course-reviews-response.dto'; @Injectable() export class CourseReviewService { constructor( @@ -28,6 +35,8 @@ export class CourseReviewService { private readonly userService: UserService, private readonly pointService: PointService, private readonly courseService: CourseService, + @Inject('CourseReviewCriteriaStrategy') + private readonly strategies: CourseReviewCriteriaStrategy[], ) {} async createCourseReview( @@ -179,6 +188,100 @@ export class CourseReviewService { ); } + async getCourseReviewsWithKeyword( + searchCourseReviewsWithKeywordRequest: SearchCourseReviewsWithKeywordRequest, + ): Promise { + const courses = await this.courseService.searchCoursesWithOnlyKeyword( + searchCourseReviewsWithKeywordRequest, + ); + + if (courses.length === 0) { + return new PaginatedCourseReviewsDto([]); + } + + const courseGroupMap = new Map< + string, + { + id: number; + courseCode: string; + professorName: string; + courseName: string; + totalRate: number; + } + >(); + + for (const course of courses) { + const key = `${course.courseCode}_${course.professorName}`; + if (!courseGroupMap.has(key)) { + courseGroupMap.set(key, { + id: course.id, + courseCode: course.courseCode, + professorName: course.professorName, + courseName: course.courseName, + totalRate: course.totalRate, + }); + } + } + const courseGroups = Array.from(courseGroupMap.values()); + + const reviewQueryBuilder = this.courseReviewRepository + .createQueryBuilder('review') + .select([ + 'MIN(review.id) AS id', + 'review.courseCode AS courseCode', + 'review.professorName AS professorName', + 'COUNT(review.id) AS reviewCount', + ]) + .groupBy('courseCode') + .addGroupBy('professorName'); + + reviewQueryBuilder.where( + new Brackets((qb) => { + courseGroups.forEach((group, index) => { + const condition = `review.courseCode = :courseCode${index} AND review.professorName = :professorName${index}`; + if (index === 0) { + qb.where(condition, { + [`courseCode${index}`]: group.courseCode, + [`professorName${index}`]: group.professorName, + }); + } else { + qb.orWhere(condition, { + [`courseCode${index}`]: group.courseCode, + [`professorName${index}`]: group.professorName, + }); + } + }); + }), + ); + + const reviewAggregates = await reviewQueryBuilder.getRawMany(); + + const reviewMap = new Map(); + reviewAggregates.forEach((item) => { + const key = `${item.courseCode}_${item.professorName}`; + reviewMap.set(key, { + reviewCount: item.reviewCount ? parseInt(item.reviewCount, 10) : 0, + }); + }); + + const responses: SearchCourseReviewsWithKeywordResponse[] = + courseGroups.map((group) => { + const key = `${group.courseCode}_${group.professorName}`; + const reviewData = reviewMap.get(key) || { + reviewCount: 0, + }; + return { + id: group.id, + courseName: group.courseName, + professorName: group.professorName, + totalRate: group.totalRate, + reviewCount: reviewData.reviewCount, + }; + }); + + return new PaginatedCourseReviewsDto(responses); + } + async getCourseReviews( user: AuthorizedUserDto, getCourseReviewsRequestDto: GetCourseReviewsRequestDto, @@ -323,4 +426,54 @@ export class CourseReviewService { return !!courseReview; } + + async getCoursesWithCourseReviews( + getCoursesWithCourseReviewsRequestDto: GetCoursesWithCourseReviewsRequestDto, + ): Promise { + const { criteria, limit } = getCoursesWithCourseReviewsRequestDto; + + const courseReviewCriteria = + await this.findCourseReviewCriteriaStrategy(criteria); + + let mainQuery = this.courseReviewRepository + .createQueryBuilder('courseReview') + .select('courseReview.courseCode', 'courseCode') + .addSelect('courseReview.professorName', 'professorName') + .groupBy('courseReview.courseCode') + .addGroupBy('courseReview.professorName'); + + mainQuery = await courseReviewCriteria.buildQuery(mainQuery); + + const courseReviews = await mainQuery.take(limit).getRawMany(); + + const courses: CourseEntity[] = []; + for (const review of courseReviews) { + const foundCourses = + await this.courseService.searchCoursesByCourseCodeAndProfessorName( + review.courseCode, + review.professorName, + review.year, + review.semester, + ); + courses.push(...foundCourses); + } + + return courses.map((course) => { + return new GetCoursesWithCourseReviewsResponseDto(course); + }); + } + + private async findCourseReviewCriteriaStrategy( + criteria: CourseReviewCriteria, + ): Promise { + const courseReviewCriteria = this.strategies.find((strategy) => + strategy.supports(criteria), + ); + + if (!courseReviewCriteria) { + throwKukeyException('COURSE_REVIEW_CRITERIA_NOT_FOUND'); + } + + return courseReviewCriteria; + } } diff --git a/src/course-review/dto/get-courses-with-course-reviews-request.dto.ts b/src/course-review/dto/get-courses-with-course-reviews-request.dto.ts new file mode 100644 index 00000000..cbec83a0 --- /dev/null +++ b/src/course-review/dto/get-courses-with-course-reviews-request.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsEnum, IsInt, IsNotEmpty, IsPositive } from 'class-validator'; +import { CourseReviewCriteria } from 'src/enums/course-review-criteria.enum'; + +export class GetCoursesWithCourseReviewsRequestDto { + @ApiProperty({ description: '반환 개수' }) + @IsInt() + @IsPositive() + @IsNotEmpty() + limit: number; + + @ApiProperty({ description: '반환 기준', enum: CourseReviewCriteria }) + @IsEnum(CourseReviewCriteria) + @IsNotEmpty() + criteria: CourseReviewCriteria; +} diff --git a/src/course-review/dto/get-courses-with-course-reviews-response.dto.ts b/src/course-review/dto/get-courses-with-course-reviews-response.dto.ts new file mode 100644 index 00000000..89317e15 --- /dev/null +++ b/src/course-review/dto/get-courses-with-course-reviews-response.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CourseEntity } from 'src/entities/course.entity'; + +export class GetCoursesWithCourseReviewsResponseDto { + @ApiProperty({ description: '강의 ID' }) + id: number; + + @ApiProperty({ description: '교수명' }) + professorName: string; + + @ApiProperty({ description: '강의 이름' }) + courseName: string; + + @ApiProperty({ description: '강의평점' }) + totalRate: number; + + @ApiProperty({ description: '연도' }) + year: string; + + @ApiProperty({ description: '학기' }) + semester: string; + + constructor(course: CourseEntity) { + this.id = course.id; + this.professorName = course.professorName; + this.courseName = course.courseName; + this.totalRate = course.totalRate; + this.year = course.year; + this.semester = course.semester; + } +} diff --git a/src/course-review/dto/paginated-course-reviews.dto.ts b/src/course-review/dto/paginated-course-reviews.dto.ts new file mode 100644 index 00000000..ef33a0c6 --- /dev/null +++ b/src/course-review/dto/paginated-course-reviews.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { SearchCourseReviewsWithKeywordResponse } from './search-course-reviews-with-keyword-response.dto'; + +export class PaginatedCourseReviewsDto { + static readonly LIMIT = 11; + + @ApiProperty({ description: '다음 페이지 존재 여부' }) + hasNextPage: boolean; + + @ApiProperty({ description: '다음 cursor id' }) + nextCursorId: number; + + @ApiProperty({ + description: '강의평 리스트', + type: [SearchCourseReviewsWithKeywordResponse], + }) + data: SearchCourseReviewsWithKeywordResponse[]; + + constructor( + searchCourseReviewsWithKeywordResponse: SearchCourseReviewsWithKeywordResponse[], + ) { + const hasNextPage = searchCourseReviewsWithKeywordResponse.length === 11; + const nextCursorId = hasNextPage + ? searchCourseReviewsWithKeywordResponse[9].id + : null; + + this.hasNextPage = hasNextPage; + this.nextCursorId = nextCursorId; + this.data = hasNextPage + ? searchCourseReviewsWithKeywordResponse.slice(0, 10) + : searchCourseReviewsWithKeywordResponse; + } +} diff --git a/src/course-review/dto/search-course-reviews-with-keyword-request.dto.ts b/src/course-review/dto/search-course-reviews-with-keyword-request.dto.ts new file mode 100644 index 00000000..0f91226b --- /dev/null +++ b/src/course-review/dto/search-course-reviews-with-keyword-request.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Length, Min } from 'class-validator'; + +export class SearchCourseReviewsWithKeywordRequest { + @ApiProperty({ + description: '검색 키워드 (교수명, 강의명, 학수번호 중 하나)', + }) + @IsString() + @Length(2) + keyword: string; + + @ApiPropertyOptional({ + description: 'cursor id, 값이 존재하지 않으면 첫 페이지', + }) + @IsInt() + @Min(0) + @IsOptional() + cursorId?: number; +} diff --git a/src/course-review/dto/search-course-reviews-with-keyword-response.dto.ts b/src/course-review/dto/search-course-reviews-with-keyword-response.dto.ts new file mode 100644 index 00000000..ff6d09b7 --- /dev/null +++ b/src/course-review/dto/search-course-reviews-with-keyword-response.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SearchCourseReviewsWithKeywordResponse { + @ApiProperty({ description: '리뷰 id' }) + id: number; + + @ApiProperty({ description: '총 평점' }) + totalRate: number; + + @ApiProperty({ description: '리뷰 개수' }) + reviewCount: number; + + @ApiProperty({ description: '과목명' }) + courseName: string; + + @ApiProperty({ description: '교수명' }) + professorName: string; +} diff --git a/src/course-review/strategy/course-review-criteria-strategy.ts b/src/course-review/strategy/course-review-criteria-strategy.ts new file mode 100644 index 00000000..831904ca --- /dev/null +++ b/src/course-review/strategy/course-review-criteria-strategy.ts @@ -0,0 +1,11 @@ +import { CourseReviewEntity } from 'src/entities/course-review.entity'; +import { CourseReviewCriteria } from 'src/enums/course-review-criteria.enum'; +import { SelectQueryBuilder } from 'typeorm'; + +export interface CourseReviewCriteriaStrategy { + supports(criteria: CourseReviewCriteria): boolean; + + buildQuery( + queryBuilder: SelectQueryBuilder, + ): Promise>; +} diff --git a/src/course-review/strategy/good-teaching-skill-reviews-strategy.ts b/src/course-review/strategy/good-teaching-skill-reviews-strategy.ts new file mode 100644 index 00000000..f59e2ad8 --- /dev/null +++ b/src/course-review/strategy/good-teaching-skill-reviews-strategy.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { CourseReviewEntity } from 'src/entities/course-review.entity'; +import { CourseReviewCriteria } from 'src/enums/course-review-criteria.enum'; +import { SelectQueryBuilder } from 'typeorm'; +import { CourseReviewCriteriaStrategy } from './course-review-criteria-strategy'; + +@Injectable() +export class GoodTeachingSkillReviewsStrategy + implements CourseReviewCriteriaStrategy +{ + supports(criteria: CourseReviewCriteria): boolean { + return criteria === CourseReviewCriteria.TEACHING; + } + + async buildQuery( + queryBuilder: SelectQueryBuilder, + ): Promise> { + return queryBuilder + .addSelect('AVG(courseReview.teachingSkills)', 'avgTeachingSkills') + .having('avgTeachingSkills >= :minTeachingSkill', { + minTeachingSkill: 4, + }) + .orderBy('avgTeachingSkills', 'DESC'); + } +} diff --git a/src/course-review/strategy/recent-course-reviews-strategy.ts b/src/course-review/strategy/recent-course-reviews-strategy.ts new file mode 100644 index 00000000..0971e823 --- /dev/null +++ b/src/course-review/strategy/recent-course-reviews-strategy.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { CourseReviewCriteriaStrategy } from './course-review-criteria-strategy'; +import { CourseReviewCriteria } from 'src/enums/course-review-criteria.enum'; +import { SelectQueryBuilder } from 'typeorm'; +import { CourseReviewEntity } from 'src/entities/course-review.entity'; + +@Injectable() +export class RecentCourseReviewsStrategy + implements CourseReviewCriteriaStrategy +{ + supports(criteria: CourseReviewCriteria): boolean { + return criteria === CourseReviewCriteria.RECENT; + } + + async buildQuery( + queryBuilder: SelectQueryBuilder, + ): Promise> { + return queryBuilder + .addSelect('MAX(courseReview.createdAt)', 'maxCreatedAt') + .orderBy('maxCreatedAt', 'DESC'); + } +} diff --git a/src/course/course.controller.ts b/src/course/course.controller.ts index 09d3ff30..8c6cf663 100644 --- a/src/course/course.controller.ts +++ b/src/course/course.controller.ts @@ -5,6 +5,8 @@ import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; import { PaginatedCoursesDto } from './dto/paginated-courses.dto'; import { CourseDocs } from 'src/decorators/docs/course.decorator'; import { SearchCourseNewDto } from './dto/search-course-new.dto'; +import { GetRecommendationCoursesRequestDto } from './dto/get-recommendation-courses-request.dto'; +import { CommonCourseResponseDto } from './dto/common-course-response.dto'; @ApiTags('course') @CourseDocs @@ -12,6 +14,16 @@ import { SearchCourseNewDto } from './dto/search-course-new.dto'; export class CourseController { constructor(private courseService: CourseService) {} + @Get('recommendation') + async getRecommendationCourses( + @Query() + getRecommendationCoursesRequestDto: GetRecommendationCoursesRequestDto, + ): Promise { + return await this.courseService.getRecommendationCourses( + getRecommendationCoursesRequestDto, + ); + } + @UseGuards(JwtAuthGuard) @Get() async searchCourses( diff --git a/src/course/course.service.ts b/src/course/course.service.ts index f7517a4b..b568b731 100644 --- a/src/course/course.service.ts +++ b/src/course/course.service.ts @@ -9,6 +9,8 @@ import { PaginatedCoursesDto } from './dto/paginated-courses.dto'; import { throwKukeyException } from 'src/utils/exception.util'; import { SearchCourseNewDto } from './dto/search-course-new.dto'; import { CourseSearchStrategy } from './strategy/course-search-strategy'; +import { SearchCourseReviewsWithKeywordRequest } from 'src/course-review/dto/search-course-reviews-with-keyword-request.dto'; +import { GetRecommendationCoursesRequestDto } from './dto/get-recommendation-courses-request.dto'; @Injectable() export class CourseService { @@ -59,11 +61,15 @@ export class CourseService { async searchCoursesByCourseCodeAndProfessorName( courseCode: string, professorName: string, + year?: string, + semester?: string, ): Promise { return await this.courseRepository.find({ where: { courseCode: Like(`${courseCode}%`), professorName, + year, + semester, }, }); } @@ -89,6 +95,72 @@ export class CourseService { return new PaginatedCoursesDto(courseInformations); } + async searchCoursesWithOnlyKeyword( + searchCourseReviewsWithKeywordRequest: SearchCourseReviewsWithKeywordRequest, + ): Promise< + { + id: number; + courseCode: string; + professorName: string; + courseName: string; + totalRate: number; + }[] + > { + const { keyword, cursorId } = searchCourseReviewsWithKeywordRequest; + const LIMIT = 10; + + const subQuery = this.courseRepository + .createQueryBuilder('course') + .select([ + 'MIN(course.id) AS id', + 'SUBSTRING(course.courseCode, 1, 7) AS courseCode', + 'course.professorName AS professorName', + ]) + .where( + new Brackets((qb) => { + qb.where('course.courseName LIKE :keyword', { + keyword: `%${keyword}%`, + }) + .orWhere('course.professorName LIKE :keyword', { + keyword: `%${keyword}%`, + }) + .orWhere('course.courseCode LIKE :keyword', { + keyword: `%${keyword}%`, + }); + }), + ) + .groupBy('courseCode, professorName'); + + const queryBuilder = this.courseRepository + .createQueryBuilder('course') + .innerJoin( + `(${subQuery.getQuery()})`, + 'subQuery', + 'subQuery.id = course.id', + ) + .setParameters(subQuery.getParameters()) + .select([ + 'course.id AS id', + 'SUBSTRING(course.courseCode, 1, 7) AS courseCode', + 'course.professorName AS professorName', + 'course.courseName AS courseName', + 'course.totalRate AS totalRate', + ]) + .orderBy('course.id', 'ASC') + .where(cursorId ? 'course.id > :cursorId' : '1=1', { cursorId }) + .limit(LIMIT + 1); + + const courseGroups = await queryBuilder.getRawMany(); + + return courseGroups.map((course) => ({ + id: course.id, + courseCode: course.courseCode, + professorName: course.professorName, + courseName: course.courseName, + totalRate: course.totalRate, + })); + } + async searchCourses( searchCourseNewDto: SearchCourseNewDto, ): Promise { @@ -138,6 +210,18 @@ export class CourseService { return await this.mappingCourseDetailsToCourses(courses); } + async getRecommendationCourses( + getRecommendationCoursesRequestDto: GetRecommendationCoursesRequestDto, + ): Promise { + const courses = await this.courseRepository.find({ + order: { totalRate: 'DESC' }, + take: getRecommendationCoursesRequestDto.limit, + relations: ['courseDetails'], + }); + + return courses.map((course) => new CommonCourseResponseDto(course)); + } + private async findSearchStrategy( searchCourseNewDto: SearchCourseNewDto, ): Promise { diff --git a/src/course/dto/common-course-response.dto.ts b/src/course/dto/common-course-response.dto.ts index bf72656a..a59213ab 100644 --- a/src/course/dto/common-course-response.dto.ts +++ b/src/course/dto/common-course-response.dto.ts @@ -41,7 +41,19 @@ export class CommonCourseResponseDto { @ApiProperty({ description: '강의평점' }) totalRate: number; - @ApiProperty({ description: '강의 세부사항' }) + @ApiProperty({ + description: '강의 세부사항', + type: 'array', + items: { + type: 'object', + properties: { + day: { type: 'string' }, + startTime: { type: 'string' }, + endTime: { type: 'string' }, + classroom: { type: 'string' }, + }, + }, + }) details: { day: string; startTime: string; diff --git a/src/course/dto/get-recommendation-courses-request.dto.ts b/src/course/dto/get-recommendation-courses-request.dto.ts new file mode 100644 index 00000000..ed259001 --- /dev/null +++ b/src/course/dto/get-recommendation-courses-request.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsNotEmpty } from 'class-validator'; + +export class GetRecommendationCoursesRequestDto { + @ApiProperty({ description: '반환 개수' }) + @IsInt() + @IsNotEmpty() + limit: number; +} diff --git a/src/decorators/docs/club.decorator.ts b/src/decorators/docs/club.decorator.ts index f9c7f939..860501f8 100644 --- a/src/decorators/docs/club.decorator.ts +++ b/src/decorators/docs/club.decorator.ts @@ -67,10 +67,10 @@ const ClubDocsMap: Record = { summary: '동아리 상세 조회', description: '동아리 상세 정보를 조회합니다.', }), - ApiQuery({ + ApiParam({ name: 'clubId', description: 'club id', - required: true, + type: Number, }), ApiQuery({ name: 'isLogin', diff --git a/src/decorators/docs/course-review.decorator.ts b/src/decorators/docs/course-review.decorator.ts index c7f470b7..c3844156 100644 --- a/src/decorators/docs/course-review.decorator.ts +++ b/src/decorators/docs/course-review.decorator.ts @@ -12,7 +12,9 @@ import { CreateCourseReviewRequestDto } from 'src/course-review/dto/create-cours import { GetCourseReviewSummaryResponseDto } from 'src/course-review/dto/get-course-review-summary-response.dto'; import { GetCourseReviewsResponseDto } from 'src/course-review/dto/get-course-reviews-response.dto'; import { ApiKukeyExceptionResponse } from '../api-kukey-exception-response'; - +import { PaginatedCourseReviewsDto } from 'src/course-review/dto/paginated-course-reviews.dto'; +import { CourseReviewCriteria } from 'src/enums/course-review-criteria.enum'; +import { GetCoursesWithCourseReviewsResponseDto } from 'src/course-review/dto/get-courses-with-course-reviews-response.dto'; type CourseReviewEndPoints = MethodNames; const CourseReviewDocsMap: Record = { @@ -83,6 +85,27 @@ const CourseReviewDocsMap: Record = { type: Boolean, }), ], + getCourseReviewsWithKeyword: [ + ApiOperation({ + summary: '강의평 검색', + description: '키워드로 강의평을 검색합니다.', + }), + ApiQuery({ + name: 'keyword', + required: true, + type: String, + }), + ApiQuery({ + name: 'cursorId', + required: false, + type: Number, + }), + ApiResponse({ + status: 200, + description: '강의평 검색 성공', + type: PaginatedCourseReviewsDto, + }), + ], getCourseReviews: [ ApiOperation({ summary: '강의평 조회', @@ -141,6 +164,28 @@ const CourseReviewDocsMap: Record = { 'SELF_REVIEW_RECOMMENDATION_FORBIDDEN', ]), ], + getCoursesWithCourseReviews: [ + ApiOperation({ + summary: '강의평과 관련된 강의 조회', + description: + '최근 강의평이 등록되었거나, 강의력이 좋은 강의를 조회합니다.', + }), + ApiQuery({ + name: 'limit', + required: true, + type: Number, + }), + ApiQuery({ + name: 'criteria', + required: true, + type: String, + }), + ApiResponse({ + status: 200, + description: '최근 강의평 혹은 강의력이 좋은 강의 조회 성공 시', + type: GetCoursesWithCourseReviewsResponseDto, + }), + ], }; export function CourseReviewDocs(target: typeof CourseReviewController) { diff --git a/src/decorators/docs/course.decorator.ts b/src/decorators/docs/course.decorator.ts index 16f74d3a..a11e8b89 100644 --- a/src/decorators/docs/course.decorator.ts +++ b/src/decorators/docs/course.decorator.ts @@ -9,6 +9,7 @@ import { CourseController } from 'src/course/course.controller'; import { PaginatedCoursesDto } from 'src/course/dto/paginated-courses.dto'; import { ApiKukeyExceptionResponse } from '../api-kukey-exception-response'; import { CourseCategory } from 'src/enums/course-category.enum'; +import { CommonCourseResponseDto } from 'src/course/dto/common-course-response.dto'; type CourseEndPoints = MethodNames; @@ -57,6 +58,22 @@ const CourseDocsMap: Record = { }), ApiKukeyExceptionResponse(['MAJOR_REQUIRED', 'COLLEGE_REQUIRED']), ], + getRecommendationCourses: [ + ApiOperation({ + summary: '추천 강의 조회', + description: '추천 강의를 조회합니다.', + }), + ApiQuery({ + name: 'limit', + required: true, + type: 'number', + }), + ApiResponse({ + status: 200, + description: '추천 강의 조회 성공 시', + type: [CommonCourseResponseDto], + }), + ], }; export function CourseDocs(target: typeof CourseController) { diff --git a/src/entities/banner.entity.ts b/src/entities/banner.entity.ts index 0e9fef2c..fe0f4240 100644 --- a/src/entities/banner.entity.ts +++ b/src/entities/banner.entity.ts @@ -11,4 +11,7 @@ export class BannerEntity extends CommonEntity { @Column('varchar', { nullable: false }) title: string; + + @Column('varchar', { nullable: true }) + link: string; } diff --git a/src/enums/course-review-criteria.enum.ts b/src/enums/course-review-criteria.enum.ts new file mode 100644 index 00000000..0171ae06 --- /dev/null +++ b/src/enums/course-review-criteria.enum.ts @@ -0,0 +1,4 @@ +export enum CourseReviewCriteria { + RECENT = 'RECENT', + TEACHING = 'TEACHING', +} diff --git a/src/home/banner/banner.controller.ts b/src/home/banner/banner.controller.ts index cf20c28e..3d9e6950 100644 --- a/src/home/banner/banner.controller.ts +++ b/src/home/banner/banner.controller.ts @@ -39,7 +39,7 @@ export class BannerController { @UploadedFile() image: Express.Multer.File, @Body() body: CreateBannerRequestDto, ): Promise { - return await this.bannerService.createBannerImage(image, body.title); + return await this.bannerService.createBannerImage(image, body); } @UseGuards(JwtAuthGuard, RolesGuard) diff --git a/src/home/banner/banner.service.ts b/src/home/banner/banner.service.ts index 6554453e..e528045e 100644 --- a/src/home/banner/banner.service.ts +++ b/src/home/banner/banner.service.ts @@ -5,6 +5,7 @@ import { BannerEntity } from 'src/entities/banner.entity'; import { Repository } from 'typeorm'; import { bannerDto } from './dto/banner.dto'; import { throwKukeyException } from 'src/utils/exception.util'; +import { CreateBannerRequestDto } from 'src/home/banner/dto/create-banner-request.dto'; @Injectable() export class BannerService { @@ -23,13 +24,14 @@ export class BannerService { id: banner.id, imageUrl: banner.imageUrl, title: banner.title, + link: banner.link ?? null, }; }); } async createBannerImage( image: Express.Multer.File, - title: string, + dto: CreateBannerRequestDto, ): Promise { if (!image) { throwKukeyException('BANNER_IMAGE_REQUIRED'); @@ -41,13 +43,15 @@ export class BannerService { const imageUrl = this.fileService.makeUrlByFileDir(fileDir); const banner = this.bannerRepository.create({ imageUrl, - title, + title: dto.title, + link: dto.link ?? null, }); const savedBanner = await this.bannerRepository.save(banner); return { id: savedBanner.id, imageUrl: savedBanner.imageUrl, title: savedBanner.title, + link: savedBanner.link ?? null, }; } diff --git a/src/home/banner/dto/banner.dto.ts b/src/home/banner/dto/banner.dto.ts index 645f8ce6..599ba144 100644 --- a/src/home/banner/dto/banner.dto.ts +++ b/src/home/banner/dto/banner.dto.ts @@ -9,4 +9,7 @@ export class bannerDto { @ApiProperty({ description: '배너 제목' }) title: string; + + @ApiProperty({ description: '배너 링크', nullable: true }) + link: string | null; } diff --git a/src/home/banner/dto/create-banner-request.dto.ts b/src/home/banner/dto/create-banner-request.dto.ts index de89ad82..ca37e397 100644 --- a/src/home/banner/dto/create-banner-request.dto.ts +++ b/src/home/banner/dto/create-banner-request.dto.ts @@ -1,5 +1,5 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional } from 'class-validator'; export class CreateBannerRequestDto { @ApiProperty({ @@ -12,4 +12,8 @@ export class CreateBannerRequestDto { @ApiProperty({ description: '배너 제목' }) @IsNotEmpty() title: string; + + @ApiPropertyOptional({ description: '링크' }) + @IsOptional() + link?: string; } diff --git a/src/home/club/club.controller.ts b/src/home/club/club.controller.ts index 56b6ea97..b648f75a 100644 --- a/src/home/club/club.controller.ts +++ b/src/home/club/club.controller.ts @@ -74,9 +74,14 @@ export class ClubController { @Get('/:clubId') async getClubDetail( @User() user: AuthorizedUserDto | null, + @Param('clubId') clubId: number, @Query() getClubDetailRequestDto: GetClubDetailRequestDto, ): Promise { - return await this.clubService.getClubDetail(user, getClubDetailRequestDto); + return await this.clubService.getClubDetail( + user, + clubId, + getClubDetailRequestDto, + ); } @UseGuards(JwtAuthGuard) diff --git a/src/home/club/club.service.ts b/src/home/club/club.service.ts index 0873791d..63adfdfd 100644 --- a/src/home/club/club.service.ts +++ b/src/home/club/club.service.ts @@ -69,9 +69,10 @@ export class ClubService { async getClubDetail( user: AuthorizedUserDto | null, + clubId: number, requetDto: GetClubDetailRequestDto, ): Promise { - const { clubId, isLogin } = requetDto; + const { isLogin } = requetDto; // isLogin이 true이나 user가 없을 경우 refresh를 위해 401 던짐 if (!user && isLogin) { diff --git a/src/home/club/dto/get-club-detail-request.dto.ts b/src/home/club/dto/get-club-detail-request.dto.ts index ac7ae441..369f2bd1 100644 --- a/src/home/club/dto/get-club-detail-request.dto.ts +++ b/src/home/club/dto/get-club-detail-request.dto.ts @@ -1,13 +1,8 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsNotEmpty, IsNumber } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNotEmpty } from 'class-validator'; import { ToBoolean } from 'src/decorators/to-boolean.decorator'; export class GetClubDetailRequestDto { - @IsNotEmpty() - @IsNumber() - @ApiProperty({ description: 'club id' }) - clubId: number; - @IsNotEmpty() @ToBoolean() @IsBoolean() diff --git a/src/utils/exception.util.ts b/src/utils/exception.util.ts index fc1a5bed..8b332030 100644 --- a/src/utils/exception.util.ts +++ b/src/utils/exception.util.ts @@ -398,6 +398,12 @@ export const kukeyExceptions = createKukeyExceptions({ errorCode: 3304, statusCode: 403, }, + COURSE_REVIEW_CRITERIA_NOT_FOUND: { + name: 'COURSE_REVIEW_CRITERIA_NOT_FOUND', + message: 'There are two criteria. (RECENT, TEACHING)', + errorCode: 3305, + statusCode: 404, + }, // - 34xx : Friendship FRIENDSHIP_NOT_FOUND: { name: 'FRIENDSHIP_NOT_FOUND',