From bfe3f2a713a20f7f81ed51007c8447d41055d32b Mon Sep 17 00:00:00 2001 From: "sw_vi.xi" Date: Sat, 20 Dec 2025 13:05:38 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20scrap=EC=97=90=EC=84=9C=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/get-scrap-search.request.dto.ts | 13 ++++++ .../scrap-search/get-scrap-search.use-case.ts | 17 ++++++++ .../query/domain/scrap.query.repository.ts | 1 + .../scrap.query.repository.impl.ts | 41 ++++++++++++++++++- .../presentation/scrap.query.controller.ts | 10 +++++ .../query/presentation/scrap.query.docs.ts | 17 +++++++- src/scrap/query/scrap.query.module.ts | 3 +- 7 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 src/scrap/query/application/scrap-search/dto/get-scrap-search.request.dto.ts create mode 100644 src/scrap/query/application/scrap-search/get-scrap-search.use-case.ts 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..1bcfdfa 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('searchScrap') + @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])], From e7e3f241570ceb580ba3d38dc2941777258cda4c Mon Sep 17 00:00:00 2001 From: "sw_vi.xi" Date: Sat, 20 Dec 2025 13:30:39 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=EC=A4=91=EC=9D=B8=20=ED=96=89=EC=82=AC=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=B4=20'=EC=9E=84=EB=B0=95=ED=95=9C'=EC=9D=BC=20?= =?UTF-8?q?=EB=95=8C=20=EC=A2=85=EB=A3=8C=EC=9D=BC=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=95=9E=EC=AA=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article.query.repository.impl.ts | 23 ++++++++++++++++--- .../presentation/scrap.query.controller.ts | 2 +- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/article/query/infrastructure/article.query.repository.impl.ts b/src/article/query/infrastructure/article.query.repository.impl.ts index 1605692..4f5662b 100644 --- a/src/article/query/infrastructure/article.query.repository.impl.ts +++ b/src/article/query/infrastructure/article.query.repository.impl.ts @@ -139,13 +139,30 @@ export class ArticleQueryRepositoryImpl implements ArticleQueryRepository { // 정렬 if (!safeSortBy || safeSortBy === 'registrationStartAt') { - // 현재 시간에 가장 가까운 순서 (미래 우선), 값 없으면 startAt 사용 + // 임박한 순서로 정렬: + // 1. 진행 중인 행사 우선 (종료일이 가까운 순) + // 2. 곧 시작하는 행사 (시작일이 가까운 순) + // 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 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 COALESCE(a.registration_start_at, a.start_at) <= NOW() AND a.end_at >= NOW() + THEN TIMESTAMPDIFF(SECOND, NOW(), a.end_at) + 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/presentation/scrap.query.controller.ts b/src/scrap/query/presentation/scrap.query.controller.ts index 1bcfdfa..7e6b029 100644 --- a/src/scrap/query/presentation/scrap.query.controller.ts +++ b/src/scrap/query/presentation/scrap.query.controller.ts @@ -18,7 +18,7 @@ export class ScrapQueryController { private readonly getScrapSearchUseCase: GetScrapSearchUseCase, ) {} - @Get('searchScrap') + @Get('search') @UseGuards(AuthGuard('jwt-access')) @ScrapQueryDocs('searchScrap') async getScrapSearch(@User() user: UserPayload, @Query() reqDto: GetScrapSearchRequestDto) { From 122c6ae5ce7adc5c45700a202924d1b8e028a84b Mon Sep 17 00:00:00 2001 From: "sw_vi.xi" Date: Sat, 20 Dec 2025 15:00:01 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EC=A7=84=ED=96=89=20=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=ED=96=89=EC=82=AC=20=EC=A0=95=EB=A0=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article.query.repository.impl.ts | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/article/query/infrastructure/article.query.repository.impl.ts b/src/article/query/infrastructure/article.query.repository.impl.ts index 4f5662b..55eee83 100644 --- a/src/article/query/infrastructure/article.query.repository.impl.ts +++ b/src/article/query/infrastructure/article.query.repository.impl.ts @@ -140,24 +140,49 @@ export class ArticleQueryRepositoryImpl implements ArticleQueryRepository { // 정렬 if (!safeSortBy || safeSortBy === 'registrationStartAt') { // 임박한 순서로 정렬: - // 1. 진행 중인 행사 우선 (종료일이 가까운 순) - // 2. 곧 시작하는 행사 (시작일이 가까운 순) - // 3. 과거 행사 (시작일이 가까운 순) + // 1. 현재 활성화된 행사들 (신청 진행 중 OR 행사 진행 중) - 가장 임박한 종료일 기준 + // 2. 곧 시작할 행사들 (registrationStartAt/startAt > NOW()) - 시작일이 가까운 순 + // 3. 과거 행사들 - 시작일이 가까운 순 // registrationStartAt이 null이면 startAt 사용 query.orderBy([ { - // 진행 중인 행사: 0, 곧 시작: 1, 과거: 2 + // 현재 활성화: 0, 곧 시작: 1, 과거: 2 [sql`CASE - WHEN COALESCE(a.registration_start_at, a.start_at) <= NOW() AND a.end_at >= NOW() THEN 0 + 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 COALESCE(a.registration_start_at, a.start_at) <= NOW() AND a.end_at >= NOW() - THEN TIMESTAMPDIFF(SECOND, NOW(), a.end_at) + 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', }, From d7bf43025a0b9e778c2a3bd46a16af37a0572519 Mon Sep 17 00:00:00 2001 From: "sw_vi.xi" Date: Sat, 20 Dec 2025 15:06:00 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20lint=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../article.command.repository.impl.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) 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();