Skip to content

Commit 4289333

Browse files
Merge pull request #132 from DevKor-github/develop
Main
2 parents e0b3a03 + cbcc5b4 commit 4289333

28 files changed

+593
-22
lines changed

src/course-review/course-review.controller.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import { TransactionInterceptor } from 'src/common/interceptors/transaction.inte
2323
import { TransactionManager } from 'src/decorators/manager.decorator';
2424
import { EntityManager } from 'typeorm';
2525
import { CourseReviewDocs } from 'src/decorators/docs/course-review.decorator';
26+
import { SearchCourseReviewsWithKeywordRequest } from './dto/search-course-reviews-with-keyword-request.dto';
27+
import { PaginatedCourseReviewsDto } from './dto/paginated-course-reviews.dto';
28+
import { GetCoursesWithCourseReviewsRequestDto } from './dto/get-courses-with-course-reviews-request.dto';
29+
import { GetCoursesWithCourseReviewsResponseDto } from './dto/get-courses-with-course-reviews-response.dto';
2630

2731
@ApiTags('course-review')
2832
@Controller('course-review')
@@ -75,6 +79,27 @@ export class CourseReviewController {
7579
);
7680
}
7781

82+
// 강의평 조회를 위한 New 검색
83+
@Get('search')
84+
async getCourseReviewsWithKeyword(
85+
@Query()
86+
searchCourseReviewsWithKeywordRequest: SearchCourseReviewsWithKeywordRequest,
87+
): Promise<PaginatedCourseReviewsDto> {
88+
return await this.courseReviewService.getCourseReviewsWithKeyword(
89+
searchCourseReviewsWithKeywordRequest,
90+
);
91+
}
92+
93+
@Get('course')
94+
async getCoursesWithCourseReviews(
95+
@Query()
96+
getCoursesWithCourseReviewsRequestDto: GetCoursesWithCourseReviewsRequestDto,
97+
): Promise<GetCoursesWithCourseReviewsResponseDto[]> {
98+
return await this.courseReviewService.getCoursesWithCourseReviews(
99+
getCoursesWithCourseReviewsRequestDto,
100+
);
101+
}
102+
78103
// 강의평 조회
79104
@Get()
80105
async getCourseReviews(

src/course-review/course-review.module.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { AuthModule } from 'src/auth/auth.module';
77
import { UserModule } from 'src/user/user.module';
88
import { CourseModule } from 'src/course/course.module';
99
import { CourseReviewRecommendEntity } from 'src/entities/course-review-recommend.entity';
10+
import { RecentCourseReviewsStrategy } from './strategy/recent-course-reviews-strategy';
11+
import { GoodTeachingSkillReviewsStrategy } from './strategy/good-teaching-skill-reviews-strategy';
1012

1113
@Module({
1214
imports: [
@@ -16,6 +18,18 @@ import { CourseReviewRecommendEntity } from 'src/entities/course-review-recommen
1618
CourseModule,
1719
],
1820
controllers: [CourseReviewController],
19-
providers: [CourseReviewService],
21+
providers: [
22+
CourseReviewService,
23+
RecentCourseReviewsStrategy,
24+
GoodTeachingSkillReviewsStrategy,
25+
{
26+
provide: 'CourseReviewCriteriaStrategy',
27+
useFactory: (
28+
recentCourseReviewsStrategy: RecentCourseReviewsStrategy,
29+
goodTeachingSkillReviewsStrategy: GoodTeachingSkillReviewsStrategy,
30+
) => [recentCourseReviewsStrategy, goodTeachingSkillReviewsStrategy],
31+
inject: [RecentCourseReviewsStrategy, GoodTeachingSkillReviewsStrategy],
32+
},
33+
],
2034
})
2135
export class CourseReviewModule {}

src/course-review/course-review.service.ts

Lines changed: 156 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Injectable } from '@nestjs/common';
1+
import { Inject, Injectable } from '@nestjs/common';
22
import { AuthorizedUserDto } from 'src/auth/dto/authorized-user-dto';
33
import { CreateCourseReviewRequestDto } from './dto/create-course-review-request.dto';
44
import { CourseReviewResponseDto } from './dto/course-review-response.dto';
@@ -9,15 +9,22 @@ import {
99
ReviewDto,
1010
} from './dto/get-course-reviews-response.dto';
1111
import { GetCourseReviewSummaryResponseDto } from './dto/get-course-review-summary-response.dto';
12-
import { EntityManager, Repository } from 'typeorm';
12+
import { Brackets, EntityManager, Repository } from 'typeorm';
1313
import { CourseReviewRecommendEntity } from 'src/entities/course-review-recommend.entity';
1414
import { CourseReviewEntity } from 'src/entities/course-review.entity';
1515
import { CourseReviewsFilterDto } from './dto/course-reviews-filter.dto';
1616
import { CourseService } from 'src/course/course.service';
1717
import { InjectRepository } from '@nestjs/typeorm';
1818
import { PointService } from 'src/user/point.service';
1919
import { throwKukeyException } from 'src/utils/exception.util';
20-
20+
import { SearchCourseReviewsWithKeywordRequest } from './dto/search-course-reviews-with-keyword-request.dto';
21+
import { SearchCourseReviewsWithKeywordResponse } from './dto/search-course-reviews-with-keyword-response.dto';
22+
import { PaginatedCourseReviewsDto } from './dto/paginated-course-reviews.dto';
23+
import { CourseEntity } from 'src/entities/course.entity';
24+
import { CourseReviewCriteriaStrategy } from './strategy/course-review-criteria-strategy';
25+
import { GetCoursesWithCourseReviewsRequestDto } from './dto/get-courses-with-course-reviews-request.dto';
26+
import { CourseReviewCriteria } from 'src/enums/course-review-criteria.enum';
27+
import { GetCoursesWithCourseReviewsResponseDto } from './dto/get-courses-with-course-reviews-response.dto';
2128
@Injectable()
2229
export class CourseReviewService {
2330
constructor(
@@ -28,6 +35,8 @@ export class CourseReviewService {
2835
private readonly userService: UserService,
2936
private readonly pointService: PointService,
3037
private readonly courseService: CourseService,
38+
@Inject('CourseReviewCriteriaStrategy')
39+
private readonly strategies: CourseReviewCriteriaStrategy[],
3140
) {}
3241

3342
async createCourseReview(
@@ -179,6 +188,100 @@ export class CourseReviewService {
179188
);
180189
}
181190

191+
async getCourseReviewsWithKeyword(
192+
searchCourseReviewsWithKeywordRequest: SearchCourseReviewsWithKeywordRequest,
193+
): Promise<PaginatedCourseReviewsDto> {
194+
const courses = await this.courseService.searchCoursesWithOnlyKeyword(
195+
searchCourseReviewsWithKeywordRequest,
196+
);
197+
198+
if (courses.length === 0) {
199+
return new PaginatedCourseReviewsDto([]);
200+
}
201+
202+
const courseGroupMap = new Map<
203+
string,
204+
{
205+
id: number;
206+
courseCode: string;
207+
professorName: string;
208+
courseName: string;
209+
totalRate: number;
210+
}
211+
>();
212+
213+
for (const course of courses) {
214+
const key = `${course.courseCode}_${course.professorName}`;
215+
if (!courseGroupMap.has(key)) {
216+
courseGroupMap.set(key, {
217+
id: course.id,
218+
courseCode: course.courseCode,
219+
professorName: course.professorName,
220+
courseName: course.courseName,
221+
totalRate: course.totalRate,
222+
});
223+
}
224+
}
225+
const courseGroups = Array.from(courseGroupMap.values());
226+
227+
const reviewQueryBuilder = this.courseReviewRepository
228+
.createQueryBuilder('review')
229+
.select([
230+
'MIN(review.id) AS id',
231+
'review.courseCode AS courseCode',
232+
'review.professorName AS professorName',
233+
'COUNT(review.id) AS reviewCount',
234+
])
235+
.groupBy('courseCode')
236+
.addGroupBy('professorName');
237+
238+
reviewQueryBuilder.where(
239+
new Brackets((qb) => {
240+
courseGroups.forEach((group, index) => {
241+
const condition = `review.courseCode = :courseCode${index} AND review.professorName = :professorName${index}`;
242+
if (index === 0) {
243+
qb.where(condition, {
244+
[`courseCode${index}`]: group.courseCode,
245+
[`professorName${index}`]: group.professorName,
246+
});
247+
} else {
248+
qb.orWhere(condition, {
249+
[`courseCode${index}`]: group.courseCode,
250+
[`professorName${index}`]: group.professorName,
251+
});
252+
}
253+
});
254+
}),
255+
);
256+
257+
const reviewAggregates = await reviewQueryBuilder.getRawMany();
258+
259+
const reviewMap = new Map<string, { reviewCount: number }>();
260+
reviewAggregates.forEach((item) => {
261+
const key = `${item.courseCode}_${item.professorName}`;
262+
reviewMap.set(key, {
263+
reviewCount: item.reviewCount ? parseInt(item.reviewCount, 10) : 0,
264+
});
265+
});
266+
267+
const responses: SearchCourseReviewsWithKeywordResponse[] =
268+
courseGroups.map((group) => {
269+
const key = `${group.courseCode}_${group.professorName}`;
270+
const reviewData = reviewMap.get(key) || {
271+
reviewCount: 0,
272+
};
273+
return {
274+
id: group.id,
275+
courseName: group.courseName,
276+
professorName: group.professorName,
277+
totalRate: group.totalRate,
278+
reviewCount: reviewData.reviewCount,
279+
};
280+
});
281+
282+
return new PaginatedCourseReviewsDto(responses);
283+
}
284+
182285
async getCourseReviews(
183286
user: AuthorizedUserDto,
184287
getCourseReviewsRequestDto: GetCourseReviewsRequestDto,
@@ -323,4 +426,54 @@ export class CourseReviewService {
323426

324427
return !!courseReview;
325428
}
429+
430+
async getCoursesWithCourseReviews(
431+
getCoursesWithCourseReviewsRequestDto: GetCoursesWithCourseReviewsRequestDto,
432+
): Promise<GetCoursesWithCourseReviewsResponseDto[]> {
433+
const { criteria, limit } = getCoursesWithCourseReviewsRequestDto;
434+
435+
const courseReviewCriteria =
436+
await this.findCourseReviewCriteriaStrategy(criteria);
437+
438+
let mainQuery = this.courseReviewRepository
439+
.createQueryBuilder('courseReview')
440+
.select('courseReview.courseCode', 'courseCode')
441+
.addSelect('courseReview.professorName', 'professorName')
442+
.groupBy('courseReview.courseCode')
443+
.addGroupBy('courseReview.professorName');
444+
445+
mainQuery = await courseReviewCriteria.buildQuery(mainQuery);
446+
447+
const courseReviews = await mainQuery.take(limit).getRawMany();
448+
449+
const courses: CourseEntity[] = [];
450+
for (const review of courseReviews) {
451+
const foundCourses =
452+
await this.courseService.searchCoursesByCourseCodeAndProfessorName(
453+
review.courseCode,
454+
review.professorName,
455+
review.year,
456+
review.semester,
457+
);
458+
courses.push(...foundCourses);
459+
}
460+
461+
return courses.map((course) => {
462+
return new GetCoursesWithCourseReviewsResponseDto(course);
463+
});
464+
}
465+
466+
private async findCourseReviewCriteriaStrategy(
467+
criteria: CourseReviewCriteria,
468+
): Promise<CourseReviewCriteriaStrategy> {
469+
const courseReviewCriteria = this.strategies.find((strategy) =>
470+
strategy.supports(criteria),
471+
);
472+
473+
if (!courseReviewCriteria) {
474+
throwKukeyException('COURSE_REVIEW_CRITERIA_NOT_FOUND');
475+
}
476+
477+
return courseReviewCriteria;
478+
}
326479
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsEnum, IsInt, IsNotEmpty, IsPositive } from 'class-validator';
3+
import { CourseReviewCriteria } from 'src/enums/course-review-criteria.enum';
4+
5+
export class GetCoursesWithCourseReviewsRequestDto {
6+
@ApiProperty({ description: '반환 개수' })
7+
@IsInt()
8+
@IsPositive()
9+
@IsNotEmpty()
10+
limit: number;
11+
12+
@ApiProperty({ description: '반환 기준', enum: CourseReviewCriteria })
13+
@IsEnum(CourseReviewCriteria)
14+
@IsNotEmpty()
15+
criteria: CourseReviewCriteria;
16+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { CourseEntity } from 'src/entities/course.entity';
3+
4+
export class GetCoursesWithCourseReviewsResponseDto {
5+
@ApiProperty({ description: '강의 ID' })
6+
id: number;
7+
8+
@ApiProperty({ description: '교수명' })
9+
professorName: string;
10+
11+
@ApiProperty({ description: '강의 이름' })
12+
courseName: string;
13+
14+
@ApiProperty({ description: '강의평점' })
15+
totalRate: number;
16+
17+
@ApiProperty({ description: '연도' })
18+
year: string;
19+
20+
@ApiProperty({ description: '학기' })
21+
semester: string;
22+
23+
constructor(course: CourseEntity) {
24+
this.id = course.id;
25+
this.professorName = course.professorName;
26+
this.courseName = course.courseName;
27+
this.totalRate = course.totalRate;
28+
this.year = course.year;
29+
this.semester = course.semester;
30+
}
31+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { SearchCourseReviewsWithKeywordResponse } from './search-course-reviews-with-keyword-response.dto';
3+
4+
export class PaginatedCourseReviewsDto {
5+
static readonly LIMIT = 11;
6+
7+
@ApiProperty({ description: '다음 페이지 존재 여부' })
8+
hasNextPage: boolean;
9+
10+
@ApiProperty({ description: '다음 cursor id' })
11+
nextCursorId: number;
12+
13+
@ApiProperty({
14+
description: '강의평 리스트',
15+
type: [SearchCourseReviewsWithKeywordResponse],
16+
})
17+
data: SearchCourseReviewsWithKeywordResponse[];
18+
19+
constructor(
20+
searchCourseReviewsWithKeywordResponse: SearchCourseReviewsWithKeywordResponse[],
21+
) {
22+
const hasNextPage = searchCourseReviewsWithKeywordResponse.length === 11;
23+
const nextCursorId = hasNextPage
24+
? searchCourseReviewsWithKeywordResponse[9].id
25+
: null;
26+
27+
this.hasNextPage = hasNextPage;
28+
this.nextCursorId = nextCursorId;
29+
this.data = hasNextPage
30+
? searchCourseReviewsWithKeywordResponse.slice(0, 10)
31+
: searchCourseReviewsWithKeywordResponse;
32+
}
33+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import { IsInt, IsOptional, IsString, Length, Min } from 'class-validator';
3+
4+
export class SearchCourseReviewsWithKeywordRequest {
5+
@ApiProperty({
6+
description: '검색 키워드 (교수명, 강의명, 학수번호 중 하나)',
7+
})
8+
@IsString()
9+
@Length(2)
10+
keyword: string;
11+
12+
@ApiPropertyOptional({
13+
description: 'cursor id, 값이 존재하지 않으면 첫 페이지',
14+
})
15+
@IsInt()
16+
@Min(0)
17+
@IsOptional()
18+
cursorId?: number;
19+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class SearchCourseReviewsWithKeywordResponse {
4+
@ApiProperty({ description: '리뷰 id' })
5+
id: number;
6+
7+
@ApiProperty({ description: '총 평점' })
8+
totalRate: number;
9+
10+
@ApiProperty({ description: '리뷰 개수' })
11+
reviewCount: number;
12+
13+
@ApiProperty({ description: '과목명' })
14+
courseName: string;
15+
16+
@ApiProperty({ description: '교수명' })
17+
professorName: string;
18+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { CourseReviewEntity } from 'src/entities/course-review.entity';
2+
import { CourseReviewCriteria } from 'src/enums/course-review-criteria.enum';
3+
import { SelectQueryBuilder } from 'typeorm';
4+
5+
export interface CourseReviewCriteriaStrategy {
6+
supports(criteria: CourseReviewCriteria): boolean;
7+
8+
buildQuery(
9+
queryBuilder: SelectQueryBuilder<CourseReviewEntity>,
10+
): Promise<SelectQueryBuilder<CourseReviewEntity>>;
11+
}

0 commit comments

Comments
 (0)