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 @@ -26,13 +26,13 @@ export class GetArticleListRequestDto {

@ApiProperty({
description: '정렬 기준',
example: 'registrationEndAt',
example: 'registrationStartAt',
required: false,
enum: ['registrationEndAt', 'scrapCount', 'viewCount'],
enum: ['registrationStartAt', 'scrapCount', 'viewCount'],
})
@IsOptional()
@IsIn(['registrationEndAt', 'scrapCount', 'viewCount'])
sortBy?: 'registrationEndAt' | 'scrapCount' | 'viewCount';
@IsIn(['registrationStartAt', 'scrapCount', 'viewCount'])
sortBy?: 'registrationStartAt' | 'scrapCount' | 'viewCount';

@ApiProperty({
description: '페이지 번호',
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 GetArticleSearchRequestDto {
@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 { ARTICLE_QUERY_REPOSITORY, ArticleQueryRepository } from '../../domain/repository/article.query.repository';
import { ArticleModel } from '../../domain/article.model';
import { GetArticleSearchRequestDto } from './dto/get-article-search.request.dto';

@Injectable()
export class GetArticleSearchUseCase {
constructor(
@Inject(ARTICLE_QUERY_REPOSITORY)
private readonly articleQueryRepository: ArticleQueryRepository,
) {}

async execute(reqDto: GetArticleSearchRequestDto): Promise<ArticleModel[]> {
const { keyword } = reqDto;
return await this.articleQueryRepository.searchByKeyword(keyword);
}
}
3 changes: 2 additions & 1 deletion src/article/query/article.query.module.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Module } from '@nestjs/common';
import { GetArticleDetailUseCase } from './application/article-detail/get-article-detail.use-case';
import { GetArticleListUseCase } from './application/article-list/get-article-list.use-case';
import { GetArticleSearchUseCase } from './application/article-search/get-article-search.use-case';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { ArticleEntity } from '../command/infrastructure/article.entity';
import { ArticleQueryController } from './presentation/article.query.controller';
import { ARTICLE_QUERY_REPOSITORY } from './domain/repository/article.query.repository';
import { ArticleQueryRepositoryImpl } from './infrastructure/article.query.repository.impl';
import { MediaEntity } from 'src/media/command/infrastructure/media.entity';

const usecases = [GetArticleDetailUseCase, GetArticleListUseCase];
const usecases = [GetArticleDetailUseCase, GetArticleListUseCase, GetArticleSearchUseCase];

@Module({
imports: [MikroOrmModule.forFeature([ArticleEntity, MediaEntity])],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ export interface ArticleQueryRepository {
findAllByCriteria(
tags?: string[],
isFinished?: boolean,
sortBy?: 'registrationEndAt' | 'scrapCount' | 'viewCount',
sortBy?: 'registrationStartAt' | 'scrapCount' | 'viewCount',
page?: number,
limit?: number,
): Promise<ArticleModel[]>;
searchByKeyword(keyword: string): Promise<ArticleModel[]>;
}

export const ARTICLE_QUERY_REPOSITORY = Symbol('ARTICLE_QUERY_REPOSITORY');
72 changes: 65 additions & 7 deletions src/article/query/infrastructure/article.query.repository.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,14 @@ export class ArticleQueryRepositoryImpl implements ArticleQueryRepository {
async findAllByCriteria(
tags?: string[],
isFinished?: boolean,
sortBy?: 'registrationEndAt' | 'scrapCount' | 'viewCount',
sortBy?: 'registrationStartAt' | 'scrapCount' | 'viewCount',
// page?: number,
// limit?: number,
): Promise<ArticleModel[]> {
const query = this.ormRepository.createQueryBuilder('a');
// 런타임 안전 가드: 허용되지 않은 sortBy 값이 오면 기본 정렬로 처리
const safeSortBy: 'registrationEndAt' | 'scrapCount' | 'viewCount' | undefined =
sortBy === 'registrationEndAt' || sortBy === 'scrapCount' || sortBy === 'viewCount' ? sortBy : undefined;
const safeSortBy: 'registrationStartAt' | 'scrapCount' | 'viewCount' | undefined =
sortBy === 'registrationStartAt' || sortBy === 'scrapCount' || sortBy === 'viewCount' ? sortBy : undefined;
query
.select([
'a.id',
Expand Down Expand Up @@ -138,15 +138,15 @@ export class ArticleQueryRepositoryImpl implements ArticleQueryRepository {
// isFinished가 true이거나 undefined면 모든 것을 조회 (필터링 없음)

// 정렬
if (!safeSortBy || safeSortBy === 'registrationEndAt') {
// 현재 시간에 가장 가까운 순서 (미래 우선), 값 없으면 endAt 사용
if (!safeSortBy || safeSortBy === 'registrationStartAt') {
// 현재 시간에 가장 가까운 순서 (미래 우선), 값 없으면 startAt 사용
query.orderBy([
{
[sql`CASE WHEN COALESCE(a.registration_end_at, a.end_at) >= NOW() THEN 0 ELSE 1 END` as unknown as string]:
[sql`CASE WHEN COALESCE(a.registration_start_at, a.start_at) >= NOW() THEN 0 ELSE 1 END` as unknown as string]:
'asc',
},
{
[sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(a.registration_end_at, a.end_at), NOW()))` as unknown as string]:
[sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(a.registration_start_at, a.start_at), NOW()))` as unknown as string]:
'asc',
},
]);
Expand Down Expand Up @@ -175,4 +175,62 @@ export class ArticleQueryRepositoryImpl implements ArticleQueryRepository {

return result;
}

async searchByKeyword(keyword: string): Promise<ArticleModel[]> {
const query = this.ormRepository.createQueryBuilder('a');
query
.select([
'a.id',
'a.title',
'a.organization',
'a.startAt',
'a.endAt',
'a.registrationStartAt',
'a.registrationEndAt',
'a.scrapCount',
'a.viewCount',
sql`(
SELECT m.media_path
FROM media m
WHERE m.article_id = a.id AND m.order = 0
LIMIT 1
) AS thumbnailPath`,
sql`group_concat(distinct tag.name) as tags`,
])
.leftJoin('a.tags', 'tag')
.groupBy('a.id');

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

// 정렬: 현재 시간에 가장 가까운 순서 (미래 우선)
query.orderBy([
{
[sql`CASE WHEN COALESCE(a.registration_start_at, a.start_at) >= NOW() THEN 0 ELSE 1 END` as unknown as string]:
'asc',
},
{
[sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(a.registration_start_at, a.start_at), NOW()))` as unknown as string]:
'asc',
},
]);

const articleEntities = await query.execute<ArticleModel[]>();

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

return result;
}
}
9 changes: 9 additions & 0 deletions src/article/query/presentation/article.query.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,28 @@ import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetArticleDetailUseCase } from '../application/article-detail/get-article-detail.use-case';
import { GetArticleListUseCase } from '../application/article-list/get-article-list.use-case';
import { GetArticleSearchUseCase } from '../application/article-search/get-article-search.use-case';
import { ArticleDetailModel } from '../domain/article-detail.model';
import { ArticleModel } from '../domain/article.model';
import { ArticleQueryDocs } from './article.query.docs';
import { GetArticleListRequestDto } from '../application/article-list/dto/get-article-list.request.dto';
import { GetArticleSearchRequestDto } from '../application/article-search/dto/get-article-search.request.dto';

@ApiTags('article')
@Controller('article')
export class ArticleQueryController {
constructor(
private readonly getArticleDetailUseCase: GetArticleDetailUseCase,
private readonly getArticleListUseCase: GetArticleListUseCase,
private readonly getArticleSearchUseCase: GetArticleSearchUseCase,
) {}

@Get('search')
@ArticleQueryDocs('search')
async getArticleSearch(@Query() reqDto: GetArticleSearchRequestDto): Promise<ArticleModel[]> {
return await this.getArticleSearchUseCase.execute(reqDto);
}

@Get(':id')
@ArticleQueryDocs('detail')
async getArticleDetail(@Param('id') articleId: string): Promise<ArticleDetailModel> {
Expand Down
21 changes: 20 additions & 1 deletion src/article/query/presentation/article.query.docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import {
} from '@nestjs/swagger';
import { createDocs } from 'src/shared/core/presentation/base.docs';
import { ArticleDetailModel } from '../domain/article-detail.model';
import { ArticleModel } from '../domain/article.model';

export type ArticleQueryEndpoint = 'list' | 'detail';
export type ArticleQueryEndpoint = 'list' | 'detail' | 'search';

export const ArticleQueryDocs = createDocs<ArticleQueryEndpoint>({
list: () =>
Expand Down Expand Up @@ -44,4 +45,22 @@ export const ArticleQueryDocs = createDocs<ArticleQueryEndpoint>({
description: '게시글을 찾을 수 없음',
}),
),

search: () =>
applyDecorators(
ApiOperation({
summary: '게시글 검색',
description: '제목을 기준으로 게시글을 검색합니다.',
}),
ApiOkResponse({
description: '게시글 검색 성공',
type: [ArticleModel],
}),
ApiBadRequestResponse({
description: '잘못된 요청',
}),
ApiInternalServerErrorResponse({
description: '서버 오류',
}),
),
});