diff --git a/src/common/validator/format-stars-as-single-decimal.validator.ts b/src/common/validator/format-stars-as-single-decimal.validator.ts new file mode 100644 index 00000000..102ede24 --- /dev/null +++ b/src/common/validator/format-stars-as-single-decimal.validator.ts @@ -0,0 +1,14 @@ +import { Transform } from 'class-transformer'; + +export function ToFixedStars() { + return function (target: any, key: string) { + Transform(({ obj }) => formatStarsAsSingleDecimal(obj[key]), { + toClassOnly: true, + })(target, key); + }; +} + +function formatStarsAsSingleDecimal(stars: string): string { + const starsNumber = parseFloat(stars); + return !isNaN(starsNumber) && starsNumber > 0 ? starsNumber.toFixed(1) : '0'; +} diff --git a/src/search/controllers/search.controller.ts b/src/search/controllers/search.controller.ts index 060af946..cef264a9 100644 --- a/src/search/controllers/search.controller.ts +++ b/src/search/controllers/search.controller.ts @@ -11,38 +11,31 @@ import { SearchService } from '@src/search/services/search.service'; import { GetAuthorizedUser } from '@src/common/decorator/get-user.decorator'; import { ValidateResult } from '@src/common/interface/common-interface'; import { CombinedSearchResultDto } from '@src/search/dtos/response/combined-search-result.dto'; -import { ApiGetCombinedSearchResult } from '@src/search/swagger-decorators/get-combined-search-result.decorator'; import { ApiTags } from '@nestjs/swagger'; import { GetCombinedSearchResultDto } from '@src/search/dtos/request/get-combined-search-result.dto'; import { GetLecturerSearchResultDto } from '@src/search/dtos/request/get-lecturer-search-result.dto'; import { SetResponseKey } from '@src/common/decorator/set-response-meta-data.decorator'; -import { ApiSearchLecturerList } from '@src/search/swagger-decorators/search-lecturer-list.decorator'; import { EsLecturerDto } from '@src/search/dtos/response/es-lecturer.dto'; import { GetLectureSearchResultDto } from '@src/search/dtos/request/get-lecture-search-result.dto'; import { EsLectureDto } from '@src/search/dtos/response/es-lecture.dto'; -import { ApiSearchLectureList } from '@src/search/swagger-decorators/search-lecture-list.decorator'; import { AllowUserLecturerAndGuestGuard } from '@src/common/guards/allow-user-lecturer-guest.guard'; import { GetUserId } from '@src/common/decorator/get-user-id.decorator'; import { GetUserSearchHistoryListDto } from '../dtos/request/get-user-search-history.dto'; import { AllowUserAndLecturerGuard } from '@src/common/guards/allow-user-lecturer.guard'; import { SearchHistoryDto } from '../dtos/response/search-history.dto'; import { plainToInstance } from 'class-transformer'; -import { ApiGetSearchHistory } from '../swagger-decorators/get-search-history.decorator'; -import { ApiDeleteSingleSearchHistory } from '../swagger-decorators/delete-single-search-history.decorator'; import { SearchPassListDto } from '../dtos/request/search-pass-list.dto'; import { EsPassDto } from '../dtos/response/es-pass.dto '; -import { ApiSearchPassList } from '../swagger-decorators/search-pass-list.decorator'; -import { IEsPass } from '../interface/search.interface'; -import { ApiDeleteAllSearchHistory } from '../swagger-decorators/delete-all-search-history.decorator'; import { PopularSearchTermDto } from '../dtos/response/popular-search-term.dto'; -import { ApiGetPopularSearchTerms } from '../swagger-decorators/get-popular-search-terms.decorator'; +import { PaginatedResponse } from '@src/common/types/type'; +import { ApiSearch } from './swagger/search.swagger'; @ApiTags('검색') @Controller('search') export class SearchController { constructor(private readonly searchService: SearchService) {} - @ApiGetCombinedSearchResult() + @ApiSearch.GetCombinedSearchResult({ summary: '통합 검색' }) @UseGuards(AllowUserLecturerAndGuestGuard) @Get() async getCombinedSearchResult( @@ -57,14 +50,13 @@ export class SearchController { return await this.searchService.getCombinedSearchResult(userId, dto); } - @ApiSearchLecturerList() - @SetResponseKey('lecturerList') + @ApiSearch.SearchLecturerList({ summary: '강사 검색' }) @UseGuards(AllowUserLecturerAndGuestGuard) @Get('/lecturer') async searchLecturerList( @GetAuthorizedUser() authorizedData: ValidateResult, @Query() dto: GetLecturerSearchResultDto, - ): Promise { + ): Promise> { const userId: number = authorizedData?.user?.id; if (userId && dto.value) { await this.searchService.saveSearchTerm(userId, dto.value); @@ -73,14 +65,13 @@ export class SearchController { return await this.searchService.getLecturerList(userId, dto); } - @ApiSearchLectureList() - @SetResponseKey('lectureList') + @ApiSearch.SearchLectureList({ summary: '강의 검색' }) @UseGuards(AllowUserLecturerAndGuestGuard) @Get('/lecture') async searchLectureList( @GetUserId() authorizedData: ValidateResult, @Query() dto: GetLectureSearchResultDto, - ): Promise { + ): Promise> { const userId: number = authorizedData?.user?.id; if (userId && dto.value) { await this.searchService.saveSearchTerm(userId, dto.value); @@ -89,28 +80,22 @@ export class SearchController { return await this.searchService.getLectureList(userId, dto); } - @ApiSearchPassList() - @SetResponseKey('searchedPassList') + @ApiSearch.SearchPassList({ summary: '패스권 검색' }) @UseGuards(AllowUserLecturerAndGuestGuard) @Get('/pass') async searchPassList( @GetUserId() authorizedData: ValidateResult, @Query() dto: SearchPassListDto, - ): Promise { + ): Promise> { const userId: number = authorizedData?.user?.id; if (userId && dto.value) { await this.searchService.saveSearchTerm(userId, dto.value); } - const passList: IEsPass[] = await this.searchService.getPassList( - userId, - dto, - ); - - return plainToInstance(EsPassDto, passList); + return await this.searchService.getPassList(userId, dto); } - @ApiGetSearchHistory() + @ApiSearch.GetSearchHistory({ summary: '최근 검색어 조회' }) @SetResponseKey('searchHistoryList') @UseGuards(AllowUserAndLecturerGuard) @Get('/history') @@ -118,27 +103,20 @@ export class SearchController { @GetUserId() authorizedData: ValidateResult, @Query() getUserSearchHistoryListDto: GetUserSearchHistoryListDto, ): Promise { - const userId: number = authorizedData?.user?.id; - - const userHistory: SearchHistoryDto[] = - await this.searchService.getSearchHistory( - userId, - getUserSearchHistoryListDto, - ); - - return plainToInstance(SearchHistoryDto, userHistory); + return await this.searchService.getSearchHistory( + authorizedData?.user?.id, + getUserSearchHistoryListDto, + ); } - @ApiGetPopularSearchTerms() + @ApiSearch.GetPopularSearchTerms({ summary: '인기 검색어 조회' }) @SetResponseKey('popularSearchTerms') @Get('/popular-terms') async getPopularSearchTerms(): Promise { - const popularSearchTerms = await this.searchService.getPopularSearchTerms(); - - return plainToInstance(PopularSearchTermDto, popularSearchTerms); + return await this.searchService.getPopularSearchTerms(); } - @ApiDeleteAllSearchHistory() + @ApiSearch.DeleteAllSearchHistory({ summary: '최근 검색어 전체 삭제' }) @UseGuards(AllowUserAndLecturerGuard) @Delete('/history') async deleteAllSearchHistory( @@ -149,7 +127,7 @@ export class SearchController { ); } - @ApiDeleteSingleSearchHistory() + @ApiSearch.DeleteSingleSearchHistory({ summary: '최근 검색어 삭제' }) @UseGuards(AllowUserAndLecturerGuard) @Delete('/history/:historyId') async deleteSingleSearchHistory( diff --git a/src/search/controllers/swagger/search.swagger.ts b/src/search/controllers/swagger/search.swagger.ts new file mode 100644 index 00000000..80c4c0d6 --- /dev/null +++ b/src/search/controllers/swagger/search.swagger.ts @@ -0,0 +1,148 @@ +import { ApiOperator } from '@src/common/types/type'; +import { OperationObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; +import { HttpStatus, applyDecorators } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { ExceptionResponseDto } from '@src/common/swagger/dtos/exeption-response.dto'; +import { StatusResponseDto } from '@src/common/swagger/dtos/status-response.dto'; +import { DetailResponseDto } from '@src/common/swagger/dtos/detail-response-dto'; +import { LecturePassWithTargetDto } from '@src/common/dtos/lecture-pass-with-target.dto'; +import { MyPassDto } from '@src/pass/dtos/pass.dto'; +import { PassWithLecturerDto } from '@src/pass/dtos/response/pass-with-lecturer.dto'; +import { IssuedPassDto } from '@src/pass/dtos/response/issued-pass.dto'; +import { PaginationResponseDto } from '@src/common/swagger/dtos/pagination-response.dto'; +import { SearchController } from '../search.controller'; +import { GeneralResponseDto } from '@src/common/swagger/dtos/general-response.dto'; +import { CombinedSearchResultDto } from '@src/search/dtos/response/combined-search-result.dto'; +import { EsLecturerDto } from '@src/search/dtos/response/es-lecturer.dto'; +import { EsLectureDto } from '@src/search/dtos/response/es-lecture.dto'; +import { EsPassDto } from '@src/search/dtos/response/es-pass.dto '; +import { SearchHistoryDto } from '@src/search/dtos/response/search-history.dto'; +import { PopularSearchTermDto } from '@src/search/dtos/response/popular-search-term.dto'; + +export const ApiSearch: ApiOperator = { + GetCombinedSearchResult: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): PropertyDecorator => { + return applyDecorators( + ApiOperation(apiOperationOptions), + ApiBearerAuth(), + GeneralResponseDto.swaggerBuilder( + HttpStatus.OK, + 'combinedResult', + CombinedSearchResultDto, + ), + ); + }, + + SearchLecturerList: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): PropertyDecorator => { + return applyDecorators( + ApiOperation(apiOperationOptions), + ApiBearerAuth(), + PaginationResponseDto.swaggerBuilder( + HttpStatus.OK, + 'lecturerList', + EsLecturerDto, + ), + ); + }, + + SearchLectureList: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): PropertyDecorator => { + return applyDecorators( + ApiOperation(apiOperationOptions), + ApiBearerAuth(), + PaginationResponseDto.swaggerBuilder( + HttpStatus.OK, + 'lectureList', + EsLectureDto, + ), + ); + }, + + SearchPassList: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): PropertyDecorator => { + return applyDecorators( + ApiOperation(apiOperationOptions), + ApiBearerAuth(), + PaginationResponseDto.swaggerBuilder( + HttpStatus.OK, + 'passList', + EsPassDto, + ), + ); + }, + + GetSearchHistory: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): PropertyDecorator => { + return applyDecorators( + ApiOperation(apiOperationOptions), + ApiBearerAuth(), + DetailResponseDto.swaggerBuilder( + HttpStatus.OK, + 'searchHistoryList', + SearchHistoryDto, + { isArray: true }, + ), + ); + }, + + GetPopularSearchTerms: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): PropertyDecorator => { + return applyDecorators( + ApiOperation(apiOperationOptions), + ApiBearerAuth(), + DetailResponseDto.swaggerBuilder( + HttpStatus.OK, + 'popularSearchTerms', + PopularSearchTermDto, + { isArray: true }, + ), + ); + }, + + DeleteAllSearchHistory: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): PropertyDecorator => { + return applyDecorators( + ApiOperation(apiOperationOptions), + ApiBearerAuth(), + StatusResponseDto.swaggerBuilder(HttpStatus.OK, 'deleteAllSearchHistory'), + ); + }, + + DeleteSingleSearchHistory: ( + apiOperationOptions: Required, 'summary'>> & + Partial, + ): PropertyDecorator => { + return applyDecorators( + ApiOperation(apiOperationOptions), + ApiBearerAuth(), + StatusResponseDto.swaggerBuilder(HttpStatus.OK, 'deleteSearchHistory'), + ExceptionResponseDto.swaggerBuilder(HttpStatus.BAD_REQUEST, [ + { + error: 'SearchHistoryNotFound', + description: '존재하지 않는 검색 기록입니다.', + }, + ]), + ExceptionResponseDto.swaggerBuilder(HttpStatus.NOT_FOUND, [ + { + error: 'MismatchedUser', + description: '유저 정보가 일치하지 않습니다.', + }, + ]), + ); + }, +}; diff --git a/src/search/dtos/response/combined-search-result.dto.ts b/src/search/dtos/response/combined-search-result.dto.ts index f012717e..35c334ab 100644 --- a/src/search/dtos/response/combined-search-result.dto.ts +++ b/src/search/dtos/response/combined-search-result.dto.ts @@ -7,49 +7,27 @@ import { import { EsLectureDto } from './es-lecture.dto'; import { EsLecturerDto } from './es-lecturer.dto'; import { EsPassDto } from './es-pass.dto '; +import { Type, plainToInstance } from 'class-transformer'; export class CombinedSearchResultDto { @ApiProperty({ description: '검색된 강사 정보', type: [EsLecturerDto], }) + @Type(() => EsLecturerDto) searchedLecturers: EsLecturerDto[]; @ApiProperty({ description: '검색된 강사 정보', type: [EsLectureDto], }) + @Type(() => EsLectureDto) searchedLectures: EsLectureDto[]; @ApiProperty({ description: '검색된 패스권 정보', type: [EsPassDto], }) + @Type(() => EsPassDto) searchedPasses: EsPassDto[]; - - constructor(combinedSearchResult: { - searchedLecturers?: IEsLecturer[]; - searchedLectures?: IEsLecture[]; - searchedPasses?: IEsPass[]; - }) { - this.searchedLecturers = combinedSearchResult.searchedLecturers - ? combinedSearchResult.searchedLecturers.map( - (searchedLecturer) => new EsLecturerDto(searchedLecturer), - ) - : []; - - this.searchedLectures = combinedSearchResult.searchedLectures - ? combinedSearchResult.searchedLectures.map( - (searchedLecture) => new EsLectureDto(searchedLecture), - ) - : []; - - this.searchedPasses = combinedSearchResult.searchedPasses - ? combinedSearchResult.searchedPasses.map( - (searchedPass) => new EsPassDto(searchedPass), - ) - : []; - - Object.assign(this); - } } diff --git a/src/search/dtos/response/es-genre.dto.ts b/src/search/dtos/response/es-genre.dto.ts index cab2a5ee..26cfc2c7 100644 --- a/src/search/dtos/response/es-genre.dto.ts +++ b/src/search/dtos/response/es-genre.dto.ts @@ -9,7 +9,7 @@ export class EsGenreDto { description: '장르 Id', }) @Expose() - @Transform(({ obj }) => obj.categoryId) + @Transform(({ obj }) => obj.categoryId, { toClassOnly: true }) id: number; @ApiProperty({ @@ -17,8 +17,4 @@ export class EsGenreDto { }) @Expose() genre: string; - - constructor(genre: Partial) { - Object.assign(this, genre); - } } diff --git a/src/search/dtos/response/es-lecture.dto.ts b/src/search/dtos/response/es-lecture.dto.ts index f377dada..9e3e267b 100644 --- a/src/search/dtos/response/es-lecture.dto.ts +++ b/src/search/dtos/response/es-lecture.dto.ts @@ -4,6 +4,7 @@ import { EsGenreDto } from './es-genre.dto'; import { EsRegionDto } from './es-region.dto'; import { EsSimpleLecturerDto } from './es-simple-lecturer.dto'; import { Exclude, Expose, Transform, Type } from 'class-transformer'; +import { ToFixedStars } from '@src/common/validator/format-stars-as-single-decimal.validator'; @Exclude() export class EsLectureDto { @@ -47,7 +48,7 @@ export class EsLectureDto { description: '시작일', }) @Expose() - @Transform(({ obj }) => obj.startdate) + @Transform(({ obj }) => obj.startdate, { toClassOnly: true }) startDate: Date; @ApiProperty({ @@ -55,7 +56,7 @@ export class EsLectureDto { description: '종료일', }) @Expose() - @Transform(({ obj }) => obj.enddate) + @Transform(({ obj }) => obj.enddate, { toClassOnly: true }) endDate: Date; @ApiProperty({ @@ -63,19 +64,20 @@ export class EsLectureDto { description: '그룹 여부', }) @Expose() - @Transform(({ obj }) => obj.isgroup) + @Transform(({ obj }) => obj.isgroup, { toClassOnly: true }) isGroup: boolean; @ApiProperty({ description: '강의 형식', }) @Expose() - @Transform(({ obj }) => obj.lecturemethod) + @Transform(({ obj }) => obj.lecturemethod, { toClassOnly: true }) lectureMethod: string; @ApiProperty({ description: '별점', }) + @ToFixedStars() @Expose() stars: string; @@ -84,7 +86,7 @@ export class EsLectureDto { description: '리뷰 수', }) @Expose() - @Transform(({ obj }) => obj.reviewcount) + @Transform(({ obj }) => obj.reviewcount, { toClassOnly: true }) reviewCount: number; @ApiProperty({ @@ -105,7 +107,7 @@ export class EsLectureDto { description: '활성화 여부', }) @Expose() - @Transform(({ obj }) => obj.isactive) + @Transform(({ obj }) => obj.isactive, { toClassOnly: true }) isActive: boolean; updatedAt: Date; @@ -124,9 +126,7 @@ export class EsLectureDto { description: '지역', }) @Expose() - @Transform(({ value }) => - value ? value.map((region) => new EsRegionDto(region)) : [], - ) + @Type(() => EsRegionDto) regions: EsRegionDto[]; @ApiProperty({ @@ -135,14 +135,6 @@ export class EsLectureDto { description: '장르', }) @Expose() - @Transform(({ value }) => - value ? value.map((genre) => new EsGenreDto(genre)) : [], - ) + @Type(() => EsGenreDto) genres: EsGenreDto[]; - - constructor(lecture: Partial) { - Object.assign(this, lecture); - - this.stars = lecture.stars === 0 ? '0' : lecture.stars.toFixed(1); - } } diff --git a/src/search/dtos/response/es-lecturer.dto.ts b/src/search/dtos/response/es-lecturer.dto.ts index 61a9678c..1b33d29a 100644 --- a/src/search/dtos/response/es-lecturer.dto.ts +++ b/src/search/dtos/response/es-lecturer.dto.ts @@ -2,7 +2,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { IEsLecturer } from '../../interface/search.interface'; import { EsRegionDto } from './es-region.dto'; import { EsGenreDto } from './es-genre.dto'; -import { Exclude, Expose, Transform } from 'class-transformer'; +import { Exclude, Expose, Transform, Type } from 'class-transformer'; +import { ToFixedStars } from '@src/common/validator/format-stars-as-single-decimal.validator'; @Exclude() export class EsLecturerDto { @@ -37,6 +38,7 @@ export class EsLecturerDto { @ApiProperty({ description: '별점', }) + @ToFixedStars() @Expose() stars: string; @@ -59,6 +61,7 @@ export class EsLecturerDto { type: Boolean, description: '좋아요 여부', }) + @Transform(({ obj }) => (obj.isliked ? true : false)) @Expose() isLiked: boolean; @@ -74,9 +77,7 @@ export class EsLecturerDto { isArray: true, description: '지역', }) - @Transform(({ value }) => - value ? value.map((region) => new EsRegionDto(region)) : [], - ) + @Type(() => EsRegionDto) @Expose() regions: EsRegionDto[]; @@ -85,19 +86,9 @@ export class EsLecturerDto { isArray: true, description: '장르', }) - @Transform(({ value }) => - value ? value.map((genre) => new EsGenreDto(genre)) : [], - ) + @Type(() => EsGenreDto) @Expose() genres: EsGenreDto[]; updatedat: Date; - - constructor(lecturer: Partial) { - Object.assign(this, lecturer); - - this.stars = lecturer.stars ? lecturer.stars.toFixed(1) : '0'; - - this.isLiked = lecturer.isLiked ? true : false; - } } diff --git a/src/search/dtos/response/es-pass.dto .ts b/src/search/dtos/response/es-pass.dto .ts index d723826d..f954e561 100644 --- a/src/search/dtos/response/es-pass.dto .ts +++ b/src/search/dtos/response/es-pass.dto .ts @@ -105,12 +105,4 @@ export class EsPassDto { @Expose() @Type(() => EsPassLecturerDto) lecturer: EsPassLecturerDto; - - constructor(pass: Partial) { - if (pass) { - Object.assign(this, pass); - this.maxUsageCount = pass.maxusagecount; - this.availableMonths = pass.availablemonths; - } - } } diff --git a/src/search/dtos/response/es-region.dto.ts b/src/search/dtos/response/es-region.dto.ts index 90d362ea..061af74e 100644 --- a/src/search/dtos/response/es-region.dto.ts +++ b/src/search/dtos/response/es-region.dto.ts @@ -8,7 +8,7 @@ export class EsRegionDto { description: '지역 Id', }) @Expose() - @Transform(({ obj }) => obj.regionId) + @Transform(({ obj }) => obj.regionId, { toClassOnly: true }) id: number; @ApiProperty({ @@ -22,8 +22,4 @@ export class EsRegionDto { }) @Expose() district: string; - - constructor(region: Partial) { - Object.assign(this, region); - } } diff --git a/src/search/dtos/response/es-simple-lecturer.dto.ts b/src/search/dtos/response/es-simple-lecturer.dto.ts index 13d1dfab..7a86004f 100644 --- a/src/search/dtos/response/es-simple-lecturer.dto.ts +++ b/src/search/dtos/response/es-simple-lecturer.dto.ts @@ -1,28 +1,27 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IEsSimpleLecturer } from '../../interface/search.interface'; +import { Exclude, Expose, Transform } from 'class-transformer'; +@Exclude() export class EsSimpleLecturerDto { @ApiProperty({ type: Number, description: '강사Id', }) + @Transform(({ obj }) => obj.lecturerId, { toClassOnly: true }) + @Expose() id: number; @ApiProperty({ description: '닉네임', }) + @Transform(({ obj }) => obj.nickname, { toClassOnly: true }) + @Expose() nickname: string; @ApiProperty({ description: '프로필 이미지', }) + @Transform(({ obj }) => obj.profileCardImageUrl, { toClassOnly: true }) + @Expose() profileCardImageUrl: string; - - constructor(lecturer: Partial) { - this.id = lecturer.lecturerId; - this.nickname = lecturer.nickname; - this.profileCardImageUrl = lecturer.profileCardImageUrl; - - Object.assign(this); - } } diff --git a/src/search/services/search.service.ts b/src/search/services/search.service.ts index 31ac1b43..2bba1e7d 100644 --- a/src/search/services/search.service.ts +++ b/src/search/services/search.service.ts @@ -52,6 +52,11 @@ import { GetUserSearchHistoryListDto } from '../dtos/request/get-user-search-his import { SearchHistoryDto } from '../dtos/response/search-history.dto'; import { SearchPassListDto } from '../dtos/request/search-pass-list.dto'; import { EsPassDto } from '../dtos/response/es-pass.dto '; +import { PaginatedResponse } from '@src/common/types/type'; +import { DateUtils } from '@src/common/utils/date.utils'; +import { plainToClass, plainToInstance } from 'class-transformer'; +import { th } from 'date-fns/locale'; +import { PopularSearchTermDto } from '../dtos/response/popular-search-term.dto'; @Injectable() export class SearchService { @@ -73,33 +78,25 @@ export class SearchService { userId, ); - const searchedLecturers: IEsLecturer[] = await this.searchLecturers( - userId, - dto, - idQueries, - ); - const searchedLectures: IEsLecture[] = await this.searchLectures( - userId, - dto, - lecturerIdQueries, - ); - const searchedPasses: IEsPass[] = await this.searchPasses( - dto, - lecturerIdQueries, - ); + const [searchedLecturers, searchedLectures, searchedPasses] = + await Promise.all([ + this.searchLecturers(userId, dto, idQueries), + this.searchLectures(userId, dto, lecturerIdQueries), + this.searchPasses(dto, lecturerIdQueries), + ]); - return new CombinedSearchResultDto({ + return { searchedLecturers, searchedLectures, searchedPasses, - }); + }; } private async searchLecturers( userId: number, { value, take }: GetCombinedSearchResultDto, idQueries: IIdQuery[], - ): Promise { + ): Promise { const searchedLecturers = await this.searchLecturersWithElasticsearch({ value, take, @@ -107,19 +104,23 @@ export class SearchService { }); if (!searchedLecturers || !userId) { - return searchedLecturers; + return plainToInstance(EsLecturerDto, searchedLecturers); } //isLiked 속성 추가 const lecturersWithLikeStatus: IEsLecturer[] = await this.addLecturerLikeStatus(userId, searchedLecturers); - return lecturersWithLikeStatus; + return plainToInstance(EsLecturerDto, lecturersWithLikeStatus); } private async getBlockedLecturerIds( userId: number, ): Promise { + if (!userId) { + return { idQueries: [], lecturerIdQueries: [] }; + } + const blockedLecturer = await this.searchRepository.getUserblockedLecturerList(userId); @@ -197,7 +198,7 @@ export class SearchService { userId: number, { value, take }: GetCombinedSearchResultDto, lecturerIdQueries: IBlockedLecturerIdQuery[], - ): Promise { + ): Promise { const searchedLectures = await this.searchLecturesWithElasticsearch({ value, take, @@ -205,14 +206,14 @@ export class SearchService { }); if (!searchedLectures || !userId) { - return searchedLectures; + return plainToInstance(EsLectureDto, searchedLectures); } //isLiked 속성 추가 const lecturesWithLikeStatus: IEsLecture[] = await this.addLectureLikeStatus(userId, searchedLectures); - return lecturesWithLikeStatus; + return plainToInstance(EsLectureDto, lecturesWithLikeStatus); } private async searchLecturesWithElasticsearch({ @@ -263,12 +264,14 @@ export class SearchService { private async searchPasses( { value, take }: GetCombinedSearchResultDto, lecturerIdQueries: IBlockedLecturerIdQuery[], - ): Promise { - return await this.searchPassesWithElasticsearch({ + ): Promise { + const searchedPasses = await this.searchPassesWithElasticsearch({ value, take, lecturerIdQueries, }); + + return plainToInstance(EsPassDto, searchedPasses); } private async searchPassesWithElasticsearch({ @@ -309,68 +312,70 @@ export class SearchService { async getLecturerList( userId: number, dto: GetLecturerSearchResultDto, - ): Promise { + ): Promise> { const { idQueries } = await this.getBlockedLecturerIds(userId); - const searchedLecturers: IEsLecturer[] = - await this.detailSearchLecturersWithElasticsearch({ ...dto, idQueries }); - if (!searchedLecturers) { - return; + const { totalItemCount, lecturerList } = + await this.detailSearchLecturersWithElasticsearch({ + ...dto, + idQueries, + }); + if (!totalItemCount) { + return { + totalItemCount, + lecturerList: [], + }; } if (!userId) { - return searchedLecturers.map((lecturer) => new EsLecturerDto(lecturer)); + return { + totalItemCount, + lecturerList: plainToInstance(EsLecturerDto, lecturerList), + }; } //isLiked 속성 추가 const lecturersWithLikeStatus: IEsLecturer[] = - await this.addLecturerLikeStatus(userId, searchedLecturers); + await this.addLecturerLikeStatus(userId, lecturerList); - return lecturersWithLikeStatus.map( - (lecturer) => new EsLecturerDto(lecturer), - ); + return { + totalItemCount, + lecturerList: plainToInstance(EsLecturerDto, lecturersWithLikeStatus), + }; } - private async detailSearchLecturersWithElasticsearch({ - value, - take, - genres, - regions, - searchAfter, - stars, - sortOption, - idQueries, - }: ILecturerSearchParams): Promise { - const searchQuery = this.buildSearchQuery(SearchTypes.LECTURER, value); - const genreQuery = this.buildGenreQuery(genres); - const starQuery = this.buildStarQuery(stars); - const regionQuery = this.buildRegionQuery(regions); - const sortQuery: any[] = this.buildSortQuery(sortOption); + private async detailSearchLecturersWithElasticsearch( + searchParams: ILecturerSearchParams, + ): Promise> { + const searchQuery = this.buildSearchQuery( + SearchTypes.LECTURER, + searchParams.value, + ); + const genreQuery = this.buildGenreQuery(searchParams.genres); + const starQuery = this.buildStarQuery(searchParams.stars); + const regionQuery = this.buildRegionQuery(searchParams.regions); + const sortQuery: any[] = this.buildSortQuery(searchParams.sortOption); const { hits } = await this.esService.search({ index: 'lecturer', - size: take, + size: searchParams.take, query: { bool: { // undefined면 에러가 발생하기 떄문에 값이 있는 쿼리만 담을 수 있도록 필터링 must: [searchQuery, genreQuery, regionQuery, starQuery].filter( Boolean, ), - must_not: idQueries, + must_not: searchParams.idQueries, }, }, - search_after: searchAfter, + search_after: searchParams.searchAfter, sort: sortQuery, }); - if (typeof hits.total === 'object' && hits.total.value > 0) { - return hits.hits.map( - (hit: any): IEsLecturer => ({ - ...hit._source, - searchAfter: hit.sort, - }), - ); - } + return this.generateESResponse( + hits, + 'lecturerList', + ); } private buildSearchQuery(searchType: SearchTypes, value: string) { @@ -505,100 +510,98 @@ export class SearchService { return regionQuery; } - private sliceResults(results: any[], take: number): any[] { - return results ? results.slice(0, take) : null; - } - async getLectureList( userId: number, dto: GetLectureSearchResultDto, - ): Promise { + ): Promise> { const { lecturerIdQueries } = await this.getBlockedLecturerIds(userId); - const searchedLectures: IEsLecture[] = + const { totalItemCount, lectureList } = await this.detailSearchLecturesWithElasticsearch({ ...dto, lecturerIdQueries, }); - if (!searchedLectures) { - return; - } - //지정 날짜 필터링 - const filteredLectures: IEsLecture[] = await this.filterLecturesByDate( - searchedLectures, - dto, - ); - if (!filteredLectures) { - return; + if (!totalItemCount) { + return { + totalItemCount, + lectureList: [], + }; } + // 지정 날짜 필터링 + const filteredLectures = await this.filterLecturesByDate(lectureList, dto); if (!userId) { - const slicedLectures = this.sliceResults(filteredLectures, dto.take); - - return slicedLectures.map((lecture) => new EsLectureDto(lecture)); + return { + totalItemCount, + lectureList: plainToInstance(EsLectureDto, filteredLectures), + }; } - //isLiked 속성 추가 - const lecturesWithLikeStatus: IEsLecture[] = - await this.addLectureLikeStatus(userId, filteredLectures); - - return lecturesWithLikeStatus.map((lecture) => new EsLectureDto(lecture)); + // isLiked 속성 추가 + const lecturesWithLike = await this.addLectureLikeStatus( + userId, + filteredLectures, + ); + return { + totalItemCount, + lectureList: plainToInstance(EsLectureDto, lecturesWithLike), + }; } - private async detailSearchLecturesWithElasticsearch({ - value, - take, - timeOfDay, - stars, - regions, - genres, - gtePrice, - ltePrice, - lectureMethod, - isGroup, - sortOption, - searchAfter, - lecturerIdQueries, - }: ILectureSearchParams): Promise { - const sortQuery: any[] = this.buildSortQuery(sortOption); - const isGroupQuery = this.buildIsGroupQuery(isGroup); - const searchQuery = this.buildSearchQuery(SearchTypes.LECTURE, value); - const timeQuery = this.buildTimeQuery(timeOfDay); - const starQuery = this.buildStarQuery(stars); - const regionQuery = this.buildRegionQuery(regions); - const genreQuery = this.buildGenreQuery(genres); - const priceQuery = this.buildPriceQuery(ltePrice, gtePrice); - const methodQuery = this.buildMethodQuery(lectureMethod); + private async detailSearchLecturesWithElasticsearch( + searchParams: ILectureSearchParams, + ): Promise> { + const sortQuery: any[] = this.buildSortQuery(searchParams.sortOption); + const isGroupQuery = this.buildIsGroupQuery(searchParams.isGroup); + const searchQuery = this.buildSearchQuery( + SearchTypes.LECTURE, + searchParams.value, + ); + const timeQuery = this.buildTimeQuery(searchParams.timeOfDay); + const starQuery = this.buildStarQuery(searchParams.stars); + const regionQuery = this.buildRegionQuery(searchParams.regions); + const genreQuery = this.buildGenreQuery(searchParams.genres); + const priceQuery = this.buildPriceQuery( + searchParams.ltePrice, + searchParams.gtePrice, + ); + const methodQuery = this.buildMethodQuery(searchParams.lectureMethod); - const { hits } = await this.esService.search({ - index: 'lecture', - size: take, - query: { - bool: { - must: [ - searchQuery, - timeQuery, - starQuery, - regionQuery, - genreQuery, - priceQuery, - methodQuery, - isGroupQuery, - ].filter(Boolean), - must_not: [...lecturerIdQueries, { term: { isactive: false } }], + try { + const { hits } = await this.esService.search({ + index: 'lecture', + size: searchParams.take, + query: { + bool: { + must: [ + searchQuery, + timeQuery, + starQuery, + regionQuery, + genreQuery, + priceQuery, + methodQuery, + isGroupQuery, + ].filter(Boolean), + must_not: [ + ...searchParams.lecturerIdQueries, + { term: { isactive: false } }, + ], + }, }, - }, - search_after: searchAfter, - sort: sortQuery, - }); + search_after: searchParams.searchAfter, + sort: sortQuery, + }); - if (typeof hits.total === 'object' && hits.total.value > 0) { - return hits.hits.map( - (hit: any): IEsLecture => ({ - ...hit._source, - searchAfter: hit.sort, - }), + return this.generateESResponse( + hits, + 'lectureList', + ); + } catch (error) { + throw new InternalServerErrorException( + `검색 서버 에러 ${error}`, + 'ElasticSearchServer', ); } } @@ -722,24 +725,16 @@ export class SearchService { } const convertedDays = days?.map((day) => Week[day as keyof typeof Week]); - const formattedGteDate = gteDate - ? new Date(gteDate.setHours(9, 0, 0, 0)) - : undefined; - - const formattedLteDate = - gteDate && lteDate - ? new Date(lteDate.setHours(32, 59, 59, 999)) - : gteDate - ? new Date(gteDate.setHours(32, 59, 59, 999)) - : undefined; + const { convertedStartDate, convertedEndDate } = + DateUtils.getUTCStartAndEndOfRange(gteDate, lteDate); const selectedLectures = await Promise.all( lectures.map(async (lecture) => { if (lecture.lecturemethod === '원데이') { return await this.searchRepository.getLecturesByDate( lecture.id, - formattedGteDate, - formattedLteDate, + convertedStartDate, + convertedEndDate, convertedDays, ); } @@ -747,8 +742,8 @@ export class SearchService { if (lecture.lecturemethod === '정기') { return await this.searchRepository.getRegularLecturesByDate( lecture.id, - formattedGteDate, - formattedLteDate, + convertedStartDate, + convertedEndDate, days, ); } @@ -800,10 +795,13 @@ export class SearchService { lastItemId, ); - return await this.searchRepository.getUserSearchHistoryList( - userId, - paginationParams, - ); + const userHistory: SearchHistoryDto[] = + await this.searchRepository.getUserSearchHistoryList( + userId, + paginationParams, + ); + + return plainToInstance(SearchHistoryDto, userHistory); } private getPaginationParams( @@ -847,44 +845,54 @@ export class SearchService { async getPassList( userId: number, dto: SearchPassListDto, - ): Promise { + ): Promise> { const { lecturerIdQueries } = await this.getBlockedLecturerIds(userId); - return await this.detailSearchPassesWithElasticsearch({ - ...dto, - lecturerIdQueries, - }); + const { totalItemCount, passList } = + await this.detailSearchPassesWithElasticsearch({ + ...dto, + lecturerIdQueries, + }); + + return { + totalItemCount, + passList: passList.length > 0 ? plainToInstance(EsPassDto, passList) : [], + }; } - private async detailSearchPassesWithElasticsearch({ - take, - sortOption, - value, - searchAfter, - lecturerIdQueries, - }: IPassSearchParams): Promise { - const searchQuery = this.buildSearchQuery(SearchTypes.PASS, value); - const sortQuery: any[] = this.buildPassSortQuery(sortOption); + private async detailSearchPassesWithElasticsearch( + searchParams: IPassSearchParams, + ): Promise> { + try { + const searchQuery = this.buildSearchQuery( + SearchTypes.PASS, + searchParams.value, + ); + const sortQuery: any[] = this.buildPassSortQuery(searchParams.sortOption); - const { hits } = await this.esService.search({ - index: 'lecture_pass', - size: take, - query: { - bool: { - must: [{ match: { isdisabled: false } }, searchQuery].filter(Boolean), - must_not: [...lecturerIdQueries, { term: { isdisabled: true } }], + const { hits } = await this.esService.search({ + index: 'lecture_pass', + size: searchParams.take, + query: { + bool: { + must: [{ match: { isdisabled: false } }, searchQuery].filter( + Boolean, + ), + must_not: [ + ...searchParams.lecturerIdQueries, + { term: { isdisabled: true } }, + ], + }, }, - }, - search_after: searchAfter, - sort: sortQuery, - }); + search_after: searchParams.searchAfter, + sort: sortQuery, + }); - if (typeof hits.total === 'object' && hits.total.value > 0) { - return hits.hits.map( - (hit: any): IEsPass => ({ - ...hit._source, - searchAfter: hit.sort, - }), + return this.generateESResponse(hits, 'passList'); + } catch (error) { + throw new InternalServerErrorException( + `검색 서버 에러 ${error}`, + 'ElasticSearchServer', ); } } @@ -915,7 +923,25 @@ export class SearchService { await this.searchRepository.deleteSearchHistoryByUserId(userId); } - async getPopularSearchTerms() { - return await this.searchRepository.getPopularSearchTerms(); + async getPopularSearchTerms(): Promise { + const popularSearchTerms = + await this.searchRepository.getPopularSearchTerms(); + + return plainToInstance(PopularSearchTermDto, popularSearchTerms); + } + + generateESResponse( + hits: any, + key: K, + ): PaginatedResponse { + const totalItemCount = + typeof hits.total === 'object' ? hits.total.value : 0; + const items = + totalItemCount > 0 ? hits.hits.map((hit: any) => hit._source) : []; + + return { + totalItemCount, + [key]: items, + } as PaginatedResponse; } } diff --git a/src/search/swagger-decorators/delete-all-search-history.decorator.ts b/src/search/swagger-decorators/delete-all-search-history.decorator.ts deleted file mode 100644 index 3ae970e6..00000000 --- a/src/search/swagger-decorators/delete-all-search-history.decorator.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { StatusResponseDto } from '@src/common/swagger/dtos/status-response.dto'; - -export function ApiDeleteAllSearchHistory() { - return applyDecorators( - ApiOperation({ - summary: '검색 기록 삭제', - }), - ApiBearerAuth(), - StatusResponseDto.swaggerBuilder(HttpStatus.OK, 'deleteAllSearchHistory'), - ); -} diff --git a/src/search/swagger-decorators/delete-single-search-history.decorator.ts b/src/search/swagger-decorators/delete-single-search-history.decorator.ts deleted file mode 100644 index 9ab47cac..00000000 --- a/src/search/swagger-decorators/delete-single-search-history.decorator.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { ExceptionResponseDto } from '@src/common/swagger/dtos/exeption-response.dto'; -import { StatusResponseDto } from '@src/common/swagger/dtos/status-response.dto'; - -export function ApiDeleteSingleSearchHistory() { - return applyDecorators( - ApiOperation({ - summary: '검색 기록 단일 삭제', - }), - ApiBearerAuth(), - StatusResponseDto.swaggerBuilder(HttpStatus.OK, 'deleteSearchHistory'), - ExceptionResponseDto.swaggerBuilder(HttpStatus.BAD_REQUEST, [ - { - error: 'SearchHistoryNotFound', - description: '존재하지 않는 검색 기록입니다.', - }, - ]), - ExceptionResponseDto.swaggerBuilder(HttpStatus.NOT_FOUND, [ - { - error: 'MismatchedUser', - description: '유저 정보가 일치하지 않습니다.', - }, - ]), - ); -} diff --git a/src/search/swagger-decorators/get-combined-search-result.decorator.ts b/src/search/swagger-decorators/get-combined-search-result.decorator.ts deleted file mode 100644 index 79a2ac48..00000000 --- a/src/search/swagger-decorators/get-combined-search-result.decorator.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { GeneralResponseDto } from '@src/common/swagger/dtos/general-response.dto'; -import { CombinedSearchResultDto } from '../dtos/response/combined-search-result.dto'; - -export function ApiGetCombinedSearchResult() { - return applyDecorators( - ApiOperation({ - summary: '통합 검색 회원/비회원 가능', - }), - ApiBearerAuth(), - GeneralResponseDto.swaggerBuilder( - HttpStatus.OK, - 'combinedResult', - CombinedSearchResultDto, - ), - ); -} diff --git a/src/search/swagger-decorators/get-popular-search-terms.decorator.ts b/src/search/swagger-decorators/get-popular-search-terms.decorator.ts deleted file mode 100644 index 1fd23fdb..00000000 --- a/src/search/swagger-decorators/get-popular-search-terms.decorator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { DetailResponseDto } from '@src/common/swagger/dtos/detail-response-dto'; -import { PopularSearchTermDto } from '../dtos/response/popular-search-term.dto'; - -export function ApiGetPopularSearchTerms() { - return applyDecorators( - ApiOperation({ - summary: '인기 검색어 조회', - }), - ApiBearerAuth(), - DetailResponseDto.swaggerBuilder( - HttpStatus.OK, - 'popularSearchTerms', - PopularSearchTermDto, - { isArray: true }, - ), - ); -} diff --git a/src/search/swagger-decorators/get-search-history.decorator.ts b/src/search/swagger-decorators/get-search-history.decorator.ts deleted file mode 100644 index dd91dd0d..00000000 --- a/src/search/swagger-decorators/get-search-history.decorator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { DetailResponseDto } from '@src/common/swagger/dtos/detail-response-dto'; -import { SearchHistoryDto } from '../dtos/response/search-history.dto'; - -export function ApiGetSearchHistory() { - return applyDecorators( - ApiOperation({ - summary: '최근 검색어 조회', - }), - ApiBearerAuth(), - DetailResponseDto.swaggerBuilder( - HttpStatus.OK, - 'searchHistoryList', - SearchHistoryDto, - { isArray: true }, - ), - ); -} diff --git a/src/search/swagger-decorators/search-lecture-list.decorator.ts b/src/search/swagger-decorators/search-lecture-list.decorator.ts deleted file mode 100644 index 349ea6fd..00000000 --- a/src/search/swagger-decorators/search-lecture-list.decorator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { DetailResponseDto } from '@src/common/swagger/dtos/detail-response-dto'; -import { EsLectureDto } from '@src/search/dtos/response/es-lecture.dto'; - -export function ApiSearchLectureList() { - return applyDecorators( - ApiOperation({ - summary: '강의 검색 회원/비회원 가능', - }), - ApiBearerAuth(), - DetailResponseDto.swaggerBuilder( - HttpStatus.OK, - 'lectureList', - EsLectureDto, - { isArray: true }, - ), - ); -} diff --git a/src/search/swagger-decorators/search-lecturer-list.decorator.ts b/src/search/swagger-decorators/search-lecturer-list.decorator.ts deleted file mode 100644 index accd9231..00000000 --- a/src/search/swagger-decorators/search-lecturer-list.decorator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { DetailResponseDto } from '@src/common/swagger/dtos/detail-response-dto'; -import { EsLecturerDto } from '../dtos/response/es-lecturer.dto'; - -export function ApiSearchLecturerList() { - return applyDecorators( - ApiOperation({ - summary: '강사 검색 회원/비회원 가능', - }), - ApiBearerAuth(), - DetailResponseDto.swaggerBuilder( - HttpStatus.OK, - 'lecturerList', - EsLecturerDto, - { isArray: true }, - ), - ); -} diff --git a/src/search/swagger-decorators/search-pass-list.decorator.ts b/src/search/swagger-decorators/search-pass-list.decorator.ts deleted file mode 100644 index 1f72eb3c..00000000 --- a/src/search/swagger-decorators/search-pass-list.decorator.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; -import { DetailResponseDto } from '@src/common/swagger/dtos/detail-response-dto'; -import { EsPassDto } from '../dtos/response/es-pass.dto '; - -export function ApiSearchPassList() { - return applyDecorators( - ApiOperation({ - summary: '패스권 검색 회원/비회원 가능', - }), - ApiBearerAuth(), - DetailResponseDto.swaggerBuilder( - HttpStatus.OK, - 'searchedPassList', - EsPassDto, - { - isArray: true, - }, - ), - ); -} diff --git a/test/unit/search.searvice.spec.ts b/test/unit/search.searvice.spec.ts new file mode 100644 index 00000000..d9695ca6 --- /dev/null +++ b/test/unit/search.searvice.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ElasticsearchService } from '@nestjs/elasticsearch'; +import { SearchService } from '@src/search/services/search.service'; +import { PrismaService } from '@src/prisma/prisma.service'; +import { PaginatedResponse } from '@src/common/types/type'; +import { SearchRepository } from '@src/search/repository/search.repository'; + +describe('SearchService', () => { + let service: SearchService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SearchService, + { + provide: ElasticsearchService, + useValue: { + search: jest.fn(), + }, + }, + { + provide: PrismaService, + useValue: {}, + }, + { + provide: SearchRepository, + useValue: {}, + }, + ], + }).compile(); + + service = module.get(SearchService); + }); + + describe('generateESResponse', () => { + it('hits가 존재할 때 items를 포함한 PaginatedResponse를 반환해야 한다', () => { + const hits = { + total: { value: 2 }, + hits: [ + { _source: { id: 1, name: 'Item 1' } }, + { _source: { id: 2, name: 'Item 2' } }, + ], + }; + + const result = service.generateESResponse(hits, 'items'); + const expected: PaginatedResponse<{ id: number; name: string }, 'items'> = + { + totalItemCount: 2, + items: [ + { id: 1, name: 'Item 1' }, + { id: 2, name: 'Item 2' }, + ], + }; + + expect(result).toEqual(expected); + }); + + it('hits가 존재하지 않을 때 빈 배열을 포함한 PaginatedResponse를 반환해야 한다', () => { + const hits = { + total: { value: 0 }, + hits: [], + }; + + const result = service.generateESResponse(hits, 'items'); + const expected: PaginatedResponse<{ id: number; name: string }, 'items'> = + { + totalItemCount: 0, + items: [], + }; + + expect(result).toEqual(expected); + }); + }); +});