From 971424f66632809ab29cb21b5b3913cd9c793ec7 Mon Sep 17 00:00:00 2001 From: JeongYeonSeung Date: Sat, 30 Nov 2024 04:19:49 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat::=20keyword=EB=A1=9C=20=EA=B0=95?= =?UTF-8?q?=EC=9D=98=20=EA=B2=80=EC=83=89=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?dto=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/course/dto/search-course.dto.ts | 14 +++++++++++++- src/course/dto/search-courses-with-keyword.dto.ts | 9 +++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/course/dto/search-courses-with-keyword.dto.ts diff --git a/src/course/dto/search-course.dto.ts b/src/course/dto/search-course.dto.ts index 28835f12..48466ccc 100644 --- a/src/course/dto/search-course.dto.ts +++ b/src/course/dto/search-course.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsInt, IsOptional, IsString, Length } from 'class-validator'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Length, +} from 'class-validator'; export class SearchCourseDto { @ApiProperty({ description: '학수 번호' }) @@ -17,6 +23,12 @@ export class SearchCourseDto { @Length(3) professorName: string; + @ApiProperty({ description: '검색 키워드 (강의명, 교수명, 학수번호)' }) + @IsString() + @Length(3) + @IsNotEmpty() + keyword: string; + @ApiPropertyOptional({ description: 'cursor id, 값이 존재하지 않으면 첫 페이지', }) diff --git a/src/course/dto/search-courses-with-keyword.dto.ts b/src/course/dto/search-courses-with-keyword.dto.ts new file mode 100644 index 00000000..1394979f --- /dev/null +++ b/src/course/dto/search-courses-with-keyword.dto.ts @@ -0,0 +1,9 @@ +import { PickType } from '@nestjs/swagger'; +import { SearchCourseDto } from './search-course.dto'; + +export class SearchCoursesWithKeywordDto extends PickType(SearchCourseDto, [ + 'keyword', + 'cursorId', + 'year', + 'semester', +] as const) {} From 5e36f6daaf1f97547e59e829e7588daa2e7d84db Mon Sep 17 00:00:00 2001 From: JeongYeonSeung Date: Sat, 30 Nov 2024 04:21:18 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat::=20keyword=EB=A1=9C=20=EA=B0=95?= =?UTF-8?q?=EC=9D=98=20=EA=B2=80=EC=83=89=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - all class, major, general, academic foundation 검색 각각 구현 - swagger docs 추가 --- src/course/course.controller.ts | 45 ++++++++++ src/course/course.service.ts | 112 +++++++++++++++++++++++- src/decorators/docs/course.decorator.ts | 61 +++++++++++++ 3 files changed, 217 insertions(+), 1 deletion(-) diff --git a/src/course/course.controller.ts b/src/course/course.controller.ts index 7d4b4d67..a5bd4832 100644 --- a/src/course/course.controller.ts +++ b/src/course/course.controller.ts @@ -11,6 +11,7 @@ import { CourseDocs } from 'src/decorators/docs/course.decorator'; import { GetGeneralCourseDto } from './dto/get-general-course.dto'; import { GetMajorCourseDto } from './dto/get-major-course.dto'; import { GetAcademicFoundationCourseDto } from './dto/get-academic-foundation-course.dto'; +import { SearchCoursesWithKeywordDto } from './dto/search-courses-with-keyword.dto'; @ApiTags('course') @CourseDocs @@ -18,6 +19,50 @@ import { GetAcademicFoundationCourseDto } from './dto/get-academic-foundation-co export class CourseController { constructor(private courseService: CourseService) {} + @UseGuards(JwtAuthGuard) + @Get('search-all') + async searchAllCourses( + @Query() searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + ): Promise { + return await this.courseService.searchAllCourses( + searchCoursesWithKeywordDto, + ); + } + + @UseGuards(JwtAuthGuard) + @Get('search-major') + async searchMajorCourses( + @Query('major') major: string, + @Query() searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + ): Promise { + return await this.courseService.searchMajorCourses( + major, + searchCoursesWithKeywordDto, + ); + } + + @UseGuards(JwtAuthGuard) + @Get('search-general') + async searchGeneralCourses( + @Query() searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + ): Promise { + return await this.courseService.searchGeneralCourses( + searchCoursesWithKeywordDto, + ); + } + + @UseGuards(JwtAuthGuard) + @Get('search-academic-foundation') + async searchAcademicFoundationCourses( + @Query('college') college: string, + @Query() searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + ): Promise { + return await this.courseService.searchAcademicFoundationCourses( + college, + searchCoursesWithKeywordDto, + ); + } + // 학수번호 검색 @UseGuards(JwtAuthGuard) @Get('search-course-code') diff --git a/src/course/course.service.ts b/src/course/course.service.ts index cd839a4a..7059760e 100644 --- a/src/course/course.service.ts +++ b/src/course/course.service.ts @@ -3,7 +3,7 @@ import { CourseRepository } from './course.repository'; import { CourseEntity } from 'src/entities/course.entity'; import { CourseDetailEntity } from 'src/entities/course-detail.entity'; import { CourseDetailRepository } from './course-detail.repository'; -import { EntityManager, Like, MoreThan } from 'typeorm'; +import { Brackets, EntityManager, Like, MoreThan } from 'typeorm'; import { CommonCourseResponseDto } from './dto/common-course-response.dto'; import { SearchCourseCodeDto } from './dto/search-course-code.dto'; import { SearchCourseNameDto } from './dto/search-course-name.dto'; @@ -13,6 +13,7 @@ import { throwKukeyException } from 'src/utils/exception.util'; import { GetGeneralCourseDto } from './dto/get-general-course.dto'; import { GetMajorCourseDto } from './dto/get-major-course.dto'; import { GetAcademicFoundationCourseDto } from './dto/get-academic-foundation-course.dto'; +import { SearchCoursesWithKeywordDto } from './dto/search-courses-with-keyword.dto'; @Injectable() export class CourseService { @@ -21,6 +22,58 @@ export class CourseService { private courseDetailRepository: CourseDetailRepository, ) {} + async searchAllCourses( + searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + ): Promise { + const courses = await this.runSearchCoursesQuery( + searchCoursesWithKeywordDto, + ); + return await this.mappingCourseDetailsToCourses(courses); + } + + async searchMajorCourses( + major: string, + searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + ): Promise { + if (!major) throwKukeyException('MAJOR_REQUIRED'); + + const courses = await this.runSearchCoursesQuery( + searchCoursesWithKeywordDto, + { + major, + category: 'Major', + }, + ); + return await this.mappingCourseDetailsToCourses(courses); + } + + async searchGeneralCourses( + searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + ): Promise { + const courses = await this.runSearchCoursesQuery( + searchCoursesWithKeywordDto, + { + category: 'General Studies', + }, + ); + return await this.mappingCourseDetailsToCourses(courses); + } + + async searchAcademicFoundationCourses( + college: string, + searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + ): Promise { + if (!college) throwKukeyException('COLLEGE_REQUIRED'); + const courses = await this.runSearchCoursesQuery( + searchCoursesWithKeywordDto, + { + college, + category: 'Academic Foundations', + }, + ); + return await this.mappingCourseDetailsToCourses(courses); + } + async getCourse(courseId: number): Promise { const course = await this.courseRepository.findOne({ where: { id: courseId }, @@ -383,4 +436,61 @@ export class CourseService { ); return new PaginatedCoursesDto(courseInformations); } + + private async runSearchCoursesQuery( + searchCoursesWithKeywordDto: SearchCoursesWithKeywordDto, + options?: { major?: string; college?: string; category?: string }, + ): Promise { + const { keyword, cursorId, year, semester } = searchCoursesWithKeywordDto; + + const LIMIT = 21; + + let queryBuilder = this.courseRepository + .createQueryBuilder('course') + .leftJoinAndSelect('course.courseDetails', 'courseDetails') + .where('course.year = :year', { year }) + .andWhere('course.semester = :semester', { semester }); + + // Optional: 추가 조건 적용 + if (options?.major) { + queryBuilder = queryBuilder.andWhere('course.major = :major', { + major: options.major, + }); + } + + if (options?.college) { + queryBuilder = queryBuilder.andWhere('course.college = :college', { + college: options.college, + }); + } + + if (options?.category) { + queryBuilder = queryBuilder.andWhere('course.category = :category', { + category: options.category, + }); + } + + // 검색 조건(LIKE) + queryBuilder = queryBuilder.andWhere( + 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}%`, + }); + }), + ); + + if (cursorId) { + queryBuilder = queryBuilder.andWhere('course.id > :cursorId', { + cursorId, + }); + } + + queryBuilder = queryBuilder.orderBy('course.id', 'ASC').take(LIMIT); + + return await queryBuilder.getMany(); + } } diff --git a/src/decorators/docs/course.decorator.ts b/src/decorators/docs/course.decorator.ts index 50b197e7..50d15880 100644 --- a/src/decorators/docs/course.decorator.ts +++ b/src/decorators/docs/course.decorator.ts @@ -14,6 +14,67 @@ import { ApiKukeyExceptionResponse } from '../api-kukey-exception-response'; type CourseEndPoints = MethodNames; const CourseDocsMap: Record = { + searchAllCourses: [ + ApiBearerAuth('accessToken'), + ApiOperation({ + summary: 'keyword로 전체 강의 검색', + description: 'keyword를 입력하여 전체 강의에서 검색합니다.', + }), + ApiResponse({ + status: 200, + description: 'keyword로 강의 검색 성공 시', + type: PaginatedCoursesDto, + }), + ], + searchMajorCourses: [ + ApiBearerAuth('accessToken'), + ApiOperation({ + summary: 'keyword로 전공 강의 검색', + description: 'keyword를 입력하여 전공 강의에서 검색합니다.', + }), + ApiQuery({ + name: 'major', + required: true, + type: 'string', + }), + ApiResponse({ + status: 200, + description: 'keyword로 강의 검색 성공 시', + type: PaginatedCoursesDto, + }), + ApiKukeyExceptionResponse(['MAJOR_REQUIRED']), + ], + searchGeneralCourses: [ + ApiBearerAuth('accessToken'), + ApiOperation({ + summary: 'keyword로 교양 강의 검색', + description: 'keyword를 입력하여 교양 강의에서 검색합니다.', + }), + ApiResponse({ + status: 200, + description: 'keyword로 강의 검색 성공 시', + type: PaginatedCoursesDto, + }), + ], + searchAcademicFoundationCourses: [ + ApiBearerAuth('accessToken'), + ApiOperation({ + summary: 'keyword로 학문의 기초 강의 검색', + description: + 'keyword를 입력하여 단과대 별 학문의 기초 강의에서 검색합니다.', + }), + ApiQuery({ + name: 'college', + required: true, + type: 'string', + }), + ApiResponse({ + status: 200, + description: 'keyword로 강의 검색 성공 시', + type: PaginatedCoursesDto, + }), + ApiKukeyExceptionResponse(['COLLEGE_REQUIRED']), + ], searchCourseCode: [ ApiBearerAuth('accessToken'), ApiOperation({ From 21582bb5d8a1614f4ecc016890e4ed79c1efdfa8 Mon Sep 17 00:00:00 2001 From: JeongYeonSeung Date: Sat, 30 Nov 2024 23:36:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix::=20=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=EB=90=9C=20=EB=B3=80=EC=88=98=20dto=EC=97=90=20static?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=84=A0=EC=96=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/course/course.service.ts | 2 +- src/course/dto/paginated-courses.dto.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/course/course.service.ts b/src/course/course.service.ts index 7059760e..686fe7a6 100644 --- a/src/course/course.service.ts +++ b/src/course/course.service.ts @@ -443,7 +443,7 @@ export class CourseService { ): Promise { const { keyword, cursorId, year, semester } = searchCoursesWithKeywordDto; - const LIMIT = 21; + const LIMIT = PaginatedCoursesDto.LIMIT; let queryBuilder = this.courseRepository .createQueryBuilder('course') diff --git a/src/course/dto/paginated-courses.dto.ts b/src/course/dto/paginated-courses.dto.ts index 446ef66d..7c1818a7 100644 --- a/src/course/dto/paginated-courses.dto.ts +++ b/src/course/dto/paginated-courses.dto.ts @@ -2,6 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { CommonCourseResponseDto } from './common-course-response.dto'; export class PaginatedCoursesDto { + static readonly LIMIT = 21; + @ApiProperty({ description: '다음 페이지 존재 여부' }) hasNextPage: boolean;