Skip to content

Main #132

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Mar 12, 2025
Merged

Main #132

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5843cd6
feat:: 새로운 강의평 검색기능을 위한 DTO 정의
Devheun Feb 9, 2025
e47ddf9
feat:: 강의평 검색기능 개발
Devheun Feb 9, 2025
6d2cd0b
docs:: Swagger 명세 작성
Devheun Feb 9, 2025
d2fa0ad
feat:: 추천 강의 조회 기능 개발
Devheun Feb 11, 2025
9fe9e79
refactor: 불필요한 DB 연산 제거
Devheun Feb 21, 2025
6d80a56
refactor: cursorId 설정
Devheun Feb 21, 2025
f90d0d8
refactor: 불필요한 것들 제거 및 페이지네이션 적용
Devheun Feb 21, 2025
587aae4
feat: 최근에 강의평이 등록된 강의 정보 반환 API 개발
Devheun Feb 23, 2025
78a66bc
refactor: 리뷰 반영
Devheun Feb 23, 2025
345034b
feat: teaching skill이 좋은 강의 조회 API 개발
Devheun Feb 24, 2025
727288d
docs:: swagger 반환 타입 수정
Devheun Mar 1, 2025
fa1761e
remove:: 안 쓰는 DTO 삭제
Devheun Mar 1, 2025
6f6e7c2
refactor:: 엔드포인트 하나로 통합
Devheun Mar 1, 2025
2288c32
refactor:: 전략패턴 적용 및 강의평이 아닌 강의가 반환되도록 변경
Devheun Mar 2, 2025
db00e5b
docs:: swagger 명세 변경
Devheun Mar 2, 2025
0770a72
refactor:: 리뷰 반영
Devheun Mar 4, 2025
6d732c9
Merge pull request #127 from DevKor-github/refactor/course
Devheun Mar 4, 2025
ee66830
fix:: 동아리 상세 조회 로직 에러 수정
JeongYeonSeung Mar 10, 2025
59c3da9
feat: 배너 이미지 link property 추가
KimSeongHyeonn Mar 11, 2025
1bd23cb
refactor: null property 명시
KimSeongHyeonn Mar 11, 2025
2e37b97
refactor: dto 타입 명확하게 수정
KimSeongHyeonn Mar 11, 2025
14d891d
docs: swagger nullable 명시
KimSeongHyeonn Mar 12, 2025
cbcc5b4
Merge pull request #131 from DevKor-github/feature/banner-link
KimSeongHyeonn Mar 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/course-review/course-review.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -75,6 +79,27 @@ export class CourseReviewController {
);
}

// 강의평 조회를 위한 New 검색
@Get('search')
async getCourseReviewsWithKeyword(
@Query()
searchCourseReviewsWithKeywordRequest: SearchCourseReviewsWithKeywordRequest,
): Promise<PaginatedCourseReviewsDto> {
return await this.courseReviewService.getCourseReviewsWithKeyword(
searchCourseReviewsWithKeywordRequest,
);
}

@Get('course')
async getCoursesWithCourseReviews(
@Query()
getCoursesWithCourseReviewsRequestDto: GetCoursesWithCourseReviewsRequestDto,
): Promise<GetCoursesWithCourseReviewsResponseDto[]> {
return await this.courseReviewService.getCoursesWithCourseReviews(
getCoursesWithCourseReviewsRequestDto,
);
}

// 강의평 조회
@Get()
async getCourseReviews(
Expand Down
16 changes: 15 additions & 1 deletion src/course-review/course-review.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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 {}
159 changes: 156 additions & 3 deletions src/course-review/course-review.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,15 +9,22 @@ 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';
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(
Expand All @@ -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(
Expand Down Expand Up @@ -179,6 +188,100 @@ export class CourseReviewService {
);
}

async getCourseReviewsWithKeyword(
searchCourseReviewsWithKeywordRequest: SearchCourseReviewsWithKeywordRequest,
): Promise<PaginatedCourseReviewsDto> {
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<string, { reviewCount: number }>();
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,
Expand Down Expand Up @@ -323,4 +426,54 @@ export class CourseReviewService {

return !!courseReview;
}

async getCoursesWithCourseReviews(
getCoursesWithCourseReviewsRequestDto: GetCoursesWithCourseReviewsRequestDto,
): Promise<GetCoursesWithCourseReviewsResponseDto[]> {
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<CourseReviewCriteriaStrategy> {
const courseReviewCriteria = this.strategies.find((strategy) =>
strategy.supports(criteria),
);

if (!courseReviewCriteria) {
throwKukeyException('COURSE_REVIEW_CRITERIA_NOT_FOUND');
}

return courseReviewCriteria;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
33 changes: 33 additions & 0 deletions src/course-review/dto/paginated-course-reviews.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions src/course-review/strategy/course-review-criteria-strategy.ts
Original file line number Diff line number Diff line change
@@ -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<CourseReviewEntity>,
): Promise<SelectQueryBuilder<CourseReviewEntity>>;
}
Loading