diff --git a/src/article/command/infrastructure/article.command.repository.impl.ts b/src/article/command/infrastructure/article.command.repository.impl.ts index 8495f99..c297894 100644 --- a/src/article/command/infrastructure/article.command.repository.impl.ts +++ b/src/article/command/infrastructure/article.command.repository.impl.ts @@ -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); @@ -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(); diff --git a/src/article/query/infrastructure/article.query.repository.impl.ts b/src/article/query/infrastructure/article.query.repository.impl.ts index 1605692..55eee83 100644 --- a/src/article/query/infrastructure/article.query.repository.impl.ts +++ b/src/article/query/infrastructure/article.query.repository.impl.ts @@ -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', }, { + // 곧 시작하거나 과거 행사: 시작일과의 절대 차이 (가까운 순) [sql`ABS(TIMESTAMPDIFF(SECOND, COALESCE(a.registration_start_at, a.start_at), NOW()))` as unknown as string]: 'asc', }, diff --git a/src/scrap/query/application/scrap-search/dto/get-scrap-search.request.dto.ts b/src/scrap/query/application/scrap-search/dto/get-scrap-search.request.dto.ts new file mode 100644 index 0000000..cdfba83 --- /dev/null +++ b/src/scrap/query/application/scrap-search/dto/get-scrap-search.request.dto.ts @@ -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; +} diff --git a/src/scrap/query/application/scrap-search/get-scrap-search.use-case.ts b/src/scrap/query/application/scrap-search/get-scrap-search.use-case.ts new file mode 100644 index 0000000..206db60 --- /dev/null +++ b/src/scrap/query/application/scrap-search/get-scrap-search.use-case.ts @@ -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 { + const { keyword } = reqDto; + return await this.scrapQueryRepository.searchByKeyword(userId, keyword); + } +} diff --git a/src/scrap/query/domain/scrap.query.repository.ts b/src/scrap/query/domain/scrap.query.repository.ts index a01fb5c..ece6a10 100644 --- a/src/scrap/query/domain/scrap.query.repository.ts +++ b/src/scrap/query/domain/scrap.query.repository.ts @@ -7,6 +7,7 @@ export interface ScrapQueryRepository { isFinished?: boolean, sortBy?: 'createdAt' | 'scrapCount' | 'viewCount', ): Promise; + searchByKeyword(userId: string, keyword: string): Promise; existsByArticleIdAndUserId(articleId: string, userId: string): Promise; } diff --git a/src/scrap/query/infrastructure/scrap.query.repository.impl.ts b/src/scrap/query/infrastructure/scrap.query.repository.impl.ts index a705b52..16129d2 100644 --- a/src/scrap/query/infrastructure/scrap.query.repository.impl.ts +++ b/src/scrap/query/infrastructure/scrap.query.repository.impl.ts @@ -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'; @@ -58,6 +58,45 @@ export class ScrapQueryRepositoryImpl implements ScrapQueryRepository { return result; } + async searchByKeyword(userId: string, keyword: string): Promise { + const qb = this.scrapOrmRepository.createQueryBuilder('s'); + + qb.where({ userId }); + + // 검색어 조건: 제목에서 검색 + qb.andWhere(`s.title LIKE ?`, [`%${keyword}%`]); + + // 정렬: 현재 시간에 가장 가까운 순서 (미래 우선) + 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', + }, + ]); + + const scrapEntities = await qb.execute(); + + 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 { const exists = await this.scrapOrmRepository.count({ articleId, userId }); diff --git a/src/scrap/query/presentation/scrap.query.controller.ts b/src/scrap/query/presentation/scrap.query.controller.ts index 0534bac..7e6b029 100644 --- a/src/scrap/query/presentation/scrap.query.controller.ts +++ b/src/scrap/query/presentation/scrap.query.controller.ts @@ -4,8 +4,10 @@ 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') @@ -13,8 +15,16 @@ 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') diff --git a/src/scrap/query/presentation/scrap.query.docs.ts b/src/scrap/query/presentation/scrap.query.docs.ts index b171d6e..0071d39 100644 --- a/src/scrap/query/presentation/scrap.query.docs.ts +++ b/src/scrap/query/presentation/scrap.query.docs.ts @@ -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({ getMyScrap: () => @@ -46,4 +46,19 @@ export const ScrapQueryDocs = createDocs({ description: '해당 게시글이 존재하지 않습니다.', }), ), + searchScrap: () => + applyDecorators( + ApiOperation({ + summary: '스크랩한 게시글 검색', + description: '사용자가 스크랩한 게시글 중에서 제목으로 검색.', + }), + ApiOkResponse({ + description: '검색 성공', + type: ScrapModel, + isArray: true, + }), + ApiUnauthorizedResponse({ + description: '유효하지 않은 access token', + }), + ), }); diff --git a/src/scrap/query/scrap.query.module.ts b/src/scrap/query/scrap.query.module.ts index cc7e080..ba129fb 100644 --- a/src/scrap/query/scrap.query.module.ts +++ b/src/scrap/query/scrap.query.module.ts @@ -1,6 +1,7 @@ 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'; @@ -8,7 +9,7 @@ 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])],