Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,9 @@ export class ArticleCommandRepositoryImpl implements ArticleCommandRepository {
const articleEntity = ArticleMapper.toEntity(article);

// Get references to existing tags
const tagRefs = await Promise.all(
article.tags.map((tag) => {
return this.em.getReference(TagEntity, tag.id.value);
}),
);
const tagRefs = article.tags.map((tag) => {
return this.em.getReference(TagEntity, tag.id.value);
});
articleEntity.tags.set(tagRefs);

await this.em.persistAndFlush(articleEntity);
Expand Down Expand Up @@ -56,11 +54,9 @@ export class ArticleCommandRepositoryImpl implements ArticleCommandRepository {
articleEntity.tags.removeAll();

// 새로운 태그 관계 설정
const tagRefs = await Promise.all(
article.tags.map((tag) => {
return this.em.getReference(TagEntity, tag.id.value);
}),
);
const tagRefs = article.tags.map((tag) => {
return this.em.getReference(TagEntity, tag.id.value);
});
articleEntity.tags.set(tagRefs);

await this.em.flush();
Expand Down
48 changes: 45 additions & 3 deletions src/article/query/infrastructure/article.query.repository.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,55 @@ export class ArticleQueryRepositoryImpl implements ArticleQueryRepository {

// 정렬
if (!safeSortBy || safeSortBy === 'registrationStartAt') {
// 현재 시간에 가장 가까운 순서 (미래 우선), 값 없으면 startAt 사용
// 임박한 순서로 정렬:
// 1. 현재 활성화된 행사들 (신청 진행 중 OR 행사 진행 중) - 가장 임박한 종료일 기준
// 2. 곧 시작할 행사들 (registrationStartAt/startAt > NOW()) - 시작일이 가까운 순
// 3. 과거 행사들 - 시작일이 가까운 순
// registrationStartAt이 null이면 startAt 사용
query.orderBy([
{
[sql`CASE WHEN COALESCE(a.registration_start_at, a.start_at) >= NOW() THEN 0 ELSE 1 END` as unknown as string]:
'asc',
// 현재 활성화: 0, 곧 시작: 1, 과거: 2
[sql`CASE
WHEN (a.registration_start_at IS NOT NULL
AND a.registration_start_at <= NOW()
AND a.registration_end_at >= NOW())
OR (COALESCE(a.registration_start_at, a.start_at) <= NOW()
AND a.end_at >= NOW()) THEN 0
WHEN COALESCE(a.registration_start_at, a.start_at) > NOW() THEN 1
ELSE 2
END` as unknown as string]: 'asc',
},
{
// 현재 활성화된 행사들: 가장 임박한 종료일 기준
// 신청 진행 중이면 신청 종료일, 행사 진행 중이면 행사 종료일, 둘 다면 더 가까운 것
[sql`CASE
WHEN (a.registration_start_at IS NOT NULL
AND a.registration_start_at <= NOW()
AND a.registration_end_at >= NOW())
OR (COALESCE(a.registration_start_at, a.start_at) <= NOW()
AND a.end_at >= NOW())
THEN LEAST(
COALESCE(
CASE WHEN a.registration_start_at IS NOT NULL
AND a.registration_start_at <= NOW()
AND a.registration_end_at >= NOW()
THEN TIMESTAMPDIFF(SECOND, NOW(), a.registration_end_at)
ELSE NULL END,
999999999
),
COALESCE(
CASE WHEN COALESCE(a.registration_start_at, a.start_at) <= NOW()
AND a.end_at >= NOW()
THEN TIMESTAMPDIFF(SECOND, NOW(), a.end_at)
ELSE NULL END,
999999999
)
)
ELSE 0
END` as unknown as string]: 'asc',
Comment on lines +150 to +187
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The complex CASE statement has duplicated logic that checks the same conditions multiple times. The conditions for determining if registration or event is in progress appear in lines 151-155 and again in lines 164-168, and once more in lines 171-180. This duplication makes the query harder to maintain and more error-prone.

Consider using CTEs (Common Table Expressions) or simplifying the logic to avoid repetition of the same conditional checks.

Copilot uses AI. Check for mistakes.
},
{
// 곧 시작하거나 과거 행사: 시작일과의 절대 차이 (가까운 순)
[sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(a.registration_start_at, a.start_at), NOW()))` as unknown as string]:
'asc',
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';

export class GetScrapSearchRequestDto {
@ApiProperty({
description: '검색어',
example: '대동제',
required: true,
})
@IsString()
@IsNotEmpty()
keyword: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Inject, Injectable } from '@nestjs/common';
import { SCRAP_QUERY_REPOSITORY, ScrapQueryRepository } from '../../domain/scrap.query.repository';
import { ScrapModel } from '../../domain/scrap.model';
import { GetScrapSearchRequestDto } from './dto/get-scrap-search.request.dto';

@Injectable()
export class GetScrapSearchUseCase {
constructor(
@Inject(SCRAP_QUERY_REPOSITORY)
private readonly scrapQueryRepository: ScrapQueryRepository,
) {}

async execute(userId: string, reqDto: GetScrapSearchRequestDto): Promise<ScrapModel[]> {
const { keyword } = reqDto;
return await this.scrapQueryRepository.searchByKeyword(userId, keyword);
}
}
1 change: 1 addition & 0 deletions src/scrap/query/domain/scrap.query.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ScrapQueryRepository {
isFinished?: boolean,
sortBy?: 'createdAt' | 'scrapCount' | 'viewCount',
): Promise<ScrapModel[]>;
searchByKeyword(userId: string, keyword: string): Promise<ScrapModel[]>;
existsByArticleIdAndUserId(articleId: string, userId: string): Promise<boolean>;
}

Expand Down
41 changes: 40 additions & 1 deletion src/scrap/query/infrastructure/scrap.query.repository.impl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EntityRepository } from '@mikro-orm/mysql';
import { EntityRepository, sql } from '@mikro-orm/mysql';
import { InjectRepository } from '@mikro-orm/nestjs';
import { ScrapQueryRepository } from '../domain/scrap.query.repository';
import { ScrapModel } from '../domain/scrap.model';
Expand Down Expand Up @@ -58,6 +58,45 @@ export class ScrapQueryRepositoryImpl implements ScrapQueryRepository {
return result;
}

async searchByKeyword(userId: string, keyword: string): Promise<ScrapModel[]> {
const qb = this.scrapOrmRepository.createQueryBuilder('s');

qb.where({ userId });

// 검색어 조건: 제목에서 검색
qb.andWhere(`s.title LIKE ?`, [`%${keyword}%`]);

// 정렬: 현재 시간에 가장 가까운 순서 (미래 우선)
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "현재 시간에 가장 가까운 순서 (미래 우선)" (closest to current time, future first) but this doesn't accurately describe the sorting behavior. The actual logic sorts by start dates (registration_start_at or start_at) rather than considering end dates for ongoing events as described in the PR.

Update the comment to accurately reflect the sorting behavior, or update the sorting logic to match what's described in the comment and the PR description.

Suggested change
// 정렬: 현재 시간에 가장 가까운 순서 (미래 우선)
// 정렬: 모집/행사 시작 시각(등록 시작일 또는 시작일)을 기준으로 현재 시간과 가장 가까운 순서 (미래 우선)

Copilot uses AI. Check for mistakes.
qb.orderBy([
{
[sql`CASE WHEN COALESCE(s.registration_start_at, s.start_at) >= NOW() THEN 0 ELSE 1 END` as unknown as string]:
'asc',
},
{
[sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(s.registration_start_at, s.start_at), NOW()))` as unknown as string]:
'asc',
Comment on lines +69 to +77
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sorting logic for scrap search does not match the PR description. According to the PR description, ongoing events should be sorted by their end dates (종료일 기준), but this implementation sorts by start dates (registration_start_at or start_at). This is inconsistent with the article query sorting logic which was updated to sort by end dates for active events.

Consider updating the sorting to match the article query logic in lines 142-194 of article.query.repository.impl.ts, which properly handles:

  1. Currently active events sorted by closest end date
  2. Future events sorted by closest start date
  3. Past events sorted by closest start date
Suggested change
// 정렬: 현재 시간에 가장 가까운 순서 (미래 우선)
qb.orderBy([
{
[sql`CASE WHEN COALESCE(s.registration_start_at, s.start_at) >= NOW() THEN 0 ELSE 1 END` as unknown as string]:
'asc',
},
{
[sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(s.registration_start_at, s.start_at), NOW()))` as unknown as string]:
'asc',
// 정렬: 진행중(종료일 기준) > 예정(시작일 기준) > 종료(시작일 기준)
qb.orderBy([
{
[
sql`
CASE
WHEN COALESCE(s.registration_start_at, s.start_at) <= NOW()
AND COALESCE(s.registration_end_at, s.end_at) >= NOW()
THEN 0 -- 진행중
WHEN COALESCE(s.registration_start_at, s.start_at) > NOW()
THEN 1 -- 예정
ELSE 2 -- 종료
END
` as unknown as string
]: 'asc',
},
{
[
sql`
CASE
WHEN COALESCE(s.registration_start_at, s.start_at) <= NOW()
AND COALESCE(s.registration_end_at, s.end_at) >= NOW()
THEN COALESCE(s.registration_end_at, s.end_at) -- 진행중: 종료일 오름차순
ELSE COALESCE(s.registration_start_at, s.start_at) -- 예정/종료: 시작일 오름차순
END
` as unknown as string
]: 'asc',

Copilot uses AI. Check for mistakes.
},
]);

const scrapEntities = await qb.execute<ScrapModel[]>();

const result = scrapEntities.map((entity) => ({
articleId: entity.articleId,
title: entity.title,
organization: entity.organization,
scrapCount: entity.scrapCount,
viewCount: entity.viewCount,
thumbnailPath: entity.thumbnailPath,
tags: entity.tags ? (entity.tags as unknown as string).split(',') : [],
startAt: entity.startAt,
endAt: entity.endAt,
registrationStartAt: entity.registrationStartAt,
registrationEndAt: entity.registrationEndAt,
}));

return result;
}

async existsByArticleIdAndUserId(articleId: string, userId: string): Promise<boolean> {
const exists = await this.scrapOrmRepository.count({ articleId, userId });

Expand Down
10 changes: 10 additions & 0 deletions src/scrap/query/presentation/scrap.query.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,27 @@ import { ApiTags } from '@nestjs/swagger';
import { User, UserPayload } from 'src/shared/core/presentation/user.decorator';
import { GetMyScrapUseCase } from '../application/get-my-scrap/get-my-scrap.use-case';
import { CheckScrapUseCase } from '../application/check-scrap/check-scrap.use-case';
import { GetScrapSearchUseCase } from '../application/scrap-search/get-scrap-search.use-case';
import { ScrapQueryDocs } from './scrap.query.docs';
import { GetMyScrapRequestDto } from './dto/get-my-scrap.request.dto';
import { GetScrapSearchRequestDto } from '../application/scrap-search/dto/get-scrap-search.request.dto';

@ApiTags('scrap')
@Controller('scrap')
export class ScrapQueryController {
constructor(
private readonly getMyScrapUseCase: GetMyScrapUseCase,
private readonly checkScrapUseCase: CheckScrapUseCase,
private readonly getScrapSearchUseCase: GetScrapSearchUseCase,
) {}

@Get('search')
@UseGuards(AuthGuard('jwt-access'))
@ScrapQueryDocs('searchScrap')
async getScrapSearch(@User() user: UserPayload, @Query() reqDto: GetScrapSearchRequestDto) {
return await this.getScrapSearchUseCase.execute(user.userId, reqDto);
}

@Get()
@UseGuards(AuthGuard('jwt-access'))
@ScrapQueryDocs('getMyScrap')
Expand Down
17 changes: 16 additions & 1 deletion src/scrap/query/presentation/scrap.query.docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createDocs } from 'src/shared/core/presentation/base.docs';
import { CheckScrapResponseDto } from '../application/check-scrap/dto/check-scrap.response.dto';
import { ScrapModel } from '../domain/scrap.model';

export type ScrapQueryEndpoint = 'getMyScrap' | 'checkScrap';
export type ScrapQueryEndpoint = 'getMyScrap' | 'checkScrap' | 'searchScrap';

export const ScrapQueryDocs = createDocs<ScrapQueryEndpoint>({
getMyScrap: () =>
Expand Down Expand Up @@ -46,4 +46,19 @@ export const ScrapQueryDocs = createDocs<ScrapQueryEndpoint>({
description: '해당 게시글이 존재하지 않습니다.',
}),
),
searchScrap: () =>
applyDecorators(
ApiOperation({
summary: '스크랩한 게시글 검색',
description: '사용자가 스크랩한 게시글 중에서 제목으로 검색.',
}),
ApiOkResponse({
description: '검색 성공',
type: ScrapModel,
isArray: true,
}),
ApiUnauthorizedResponse({
description: '유효하지 않은 access token',
}),
),
});
3 changes: 2 additions & 1 deletion src/scrap/query/scrap.query.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Module } from '@nestjs/common';
import { GetMyScrapUseCase } from './application/get-my-scrap/get-my-scrap.use-case';
import { CheckScrapUseCase } from './application/check-scrap/check-scrap.use-case';
import { GetScrapSearchUseCase } from './application/scrap-search/get-scrap-search.use-case';
import { ScrapQueryController } from './presentation/scrap.query.controller';
import { SCRAP_QUERY_REPOSITORY } from './domain/scrap.query.repository';
import { ScrapQueryRepositoryImpl } from './infrastructure/scrap.query.repository.impl';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { ScrapViewEntity } from './infrastructure/scrap.view.entity';
import { ScrapEntity } from '../command/infrastructure/scrap.entity';

const useCases = [GetMyScrapUseCase, CheckScrapUseCase];
const useCases = [GetMyScrapUseCase, CheckScrapUseCase, GetScrapSearchUseCase];

@Module({
imports: [MikroOrmModule.forFeature([ScrapEntity, ScrapViewEntity])],
Expand Down