From 32e03013594b10350a4cf82f66f2c91541cd9b93 Mon Sep 17 00:00:00 2001 From: stoneTiger0912 Date: Wed, 18 Mar 2026 14:23:54 +0900 Subject: [PATCH 1/6] =?UTF-8?q?Feat:=20=EC=B9=B4=EB=93=9C=EC=85=8B=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/application/cardset.use-case.ts | 79 +++++++++++++++++++ .../dto/request/cardset-search.request.ts | 47 +++++++++++ .../infrastructure/http/cardset.controller.ts | 26 ++++++ src/shared/common/paged-response.ts | 31 ++++++++ 4 files changed, 183 insertions(+) create mode 100644 src/cardset/application/dto/request/cardset-search.request.ts create mode 100644 src/shared/common/paged-response.ts diff --git a/src/cardset/application/cardset.use-case.ts b/src/cardset/application/cardset.use-case.ts index d85714d..de3318a 100644 --- a/src/cardset/application/cardset.use-case.ts +++ b/src/cardset/application/cardset.use-case.ts @@ -22,6 +22,7 @@ import type { UserInfo } from '../infrastructure/grpc/user-grpc.client'; import { CreateCardsetRequest } from './dto/request/create-cardset.request'; import { UpdateCardsetRequest } from './dto/request/update-cardset.request'; import { CollaborationUseCase } from '../../collaboration/application/collaboration.use-case'; +import { CardsetSearchRequest } from './dto/request/cardset-search.request'; @Injectable() export class CardsetUseCase { @@ -129,6 +130,84 @@ export class CardsetUseCase { return result; } + async findAllPaged( + req: CardsetSearchRequest, + userId: number, + ): Promise<{ + items: { + cardset: Cardset; + imageUrl: string; + likeCount: number; + bookmarkCount: number; + liked: boolean; + bookmarked: boolean; + managers: UserInfo[]; + }[]; + total: number; + page: number; + size: number; + }> { + const page = req.page ?? 1; + const size = req.size ?? 10; + + const { items, total } = await this.cardsetRepository.findAllPaged({ + page: page - 1, + size, + sortBy: req.sortBy, + order: (req.order?.toUpperCase() as 'ASC' | 'DESC') ?? 'DESC', + keyword: req.keyword, + category: req.category, + }); + + const visibleCardsets: Cardset[] = []; + for (const cardset of items) { + const canView = + cardset.visibility === Visibility.PUBLIC || + (await this.groupGrpcClient.isUserInGroup(cardset.groupId, userId)); + if (canView) visibleCardsets.push(cardset); + } + + const ids = visibleCardsets.map((c) => c.id); + const [metadataMap, likedMap, bookmarkedMap, managersMap] = + await Promise.all([ + this.metadataRepository.findByCardSetIds(ids), + this.skipReactionGrpc + ? Promise.resolve(new Map()) + : this.reactionGrpcClient.areLiked(ids, userId), + this.skipReactionGrpc + ? Promise.resolve(new Map()) + : this.reactionGrpcClient.areBookmarked(ids, userId), + this.getManagersForCardSets(ids), + ]); + + const result: { + cardset: Cardset; + imageUrl: string; + likeCount: number; + bookmarkCount: number; + liked: boolean; + bookmarked: boolean; + managers: UserInfo[]; + }[] = []; + for (const cardset of visibleCardsets) { + const imageUrl = cardset.imageRefId + ? await this.imageGrpcClient.getImageUrl(cardset.id) + : this.defaultImageUrl; + const meta = metadataMap.get(cardset.id); + result.push({ + cardset, + imageUrl, + likeCount: meta?.likeCount ?? 0, + bookmarkCount: meta?.bookmarkCount ?? 0, + liked: likedMap.get(cardset.id) ?? false, + bookmarked: bookmarkedMap.get(cardset.id) ?? false, + managers: managersMap.get(cardset.id) ?? [], + }); + } + + return { items: result, total, page, size }; + } + async findAll(userId: number): Promise< { cardset: Cardset; diff --git a/src/cardset/application/dto/request/cardset-search.request.ts b/src/cardset/application/dto/request/cardset-search.request.ts new file mode 100644 index 0000000..c336c13 --- /dev/null +++ b/src/cardset/application/dto/request/cardset-search.request.ts @@ -0,0 +1,47 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class CardsetSearchRequest { + @ApiPropertyOptional({ example: 1, description: '페이지 번호 (1부터 시작)' }) + @IsOptional() + @IsInt() + @Min(1) + @Type(() => Number) + page?: number = 1; + + @ApiPropertyOptional({ example: 10, description: '페이지당 항목 수' }) + @IsOptional() + @IsInt() + @Min(1) + @Max(30) + @Type(() => Number) + size?: number = 10; + + @ApiPropertyOptional({ + example: 'createdAt', + description: '정렬 기준 (createdAt, name, cardCount)', + }) + @IsOptional() + @IsString() + sortBy?: string; + + @ApiPropertyOptional({ + example: 'desc', + enum: ['asc', 'desc'], + description: '정렬 방향', + }) + @IsOptional() + @IsIn(['asc', 'desc', 'ASC', 'DESC']) + order?: string = 'desc'; + + @ApiPropertyOptional({ example: '영어', description: '카드셋 이름 검색 키워드' }) + @IsOptional() + @IsString() + keyword?: string; + + @ApiPropertyOptional({ example: '언어', description: '카테고리 필터' }) + @IsOptional() + @IsString() + category?: string; +} diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts index 9752ba7..0c9033a 100644 --- a/src/cardset/infrastructure/http/cardset.controller.ts +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -7,6 +7,7 @@ import { Body, Param, Headers, + HttpCode, } from '@nestjs/common'; import { @@ -17,13 +18,16 @@ import { ApiExtraModels, } from '@nestjs/swagger'; import { CardsetUseCase } from '../../application/cardset.use-case'; +import { CardsetSearchRequest } from '../../application/dto/request/cardset-search.request'; import { CreateCardsetRequest } from '../../application/dto/request/create-cardset.request'; import { UpdateCardsetRequest } from '../../application/dto/request/update-cardset.request'; import { CardsetCreateResponse } from '../../application/dto/response/cardset-create.response'; +import { CardsetListItemResponse } from '../../application/dto/response/cardset-list-item.response'; import { CardsetResponse } from '../../application/dto/response/cardset.response'; import { ManagerInfoResponse } from '../../application/dto/response/manager-info.response'; import { YjsCardResponse } from '../../application/dto/response/yjs-card.response'; import { ApiResponse } from '../../../shared/common/api-response'; +import { PagedResponse } from '../../../shared/common/paged-response'; @ApiExtraModels(CardsetResponse, ManagerInfoResponse) @ApiTags('card-sets') @@ -31,6 +35,28 @@ import { ApiResponse } from '../../../shared/common/api-response'; export class CardsetController { constructor(private readonly cardsetUseCase: CardsetUseCase) {} + @Post('search') + @HttpCode(200) + @ApiOperation({ summary: '카드셋 전체 목록 조회 (페이징)' }) + @SwaggerApiResponse({ + status: 200, + description: '조회 성공', + }) + async search( + @Headers('X-USER-ID') userId: string, + @Body() dto: CardsetSearchRequest, + ): Promise>> { + const { items, total, page, size } = await this.cardsetUseCase.findAllPaged( + dto, + parseInt(userId), + ); + const responseItems = items.map( + ({ cardset, imageUrl, likeCount, bookmarkCount, liked, bookmarked, managers }) => + CardsetListItemResponse.from(cardset, imageUrl, liked, bookmarked, managers, likeCount, bookmarkCount), + ); + return ApiResponse.success(PagedResponse.of(responseItems, total, page, size)); + } + @Post() @ApiOperation({ summary: '카드셋 생성' }) @SwaggerApiResponse({ diff --git a/src/shared/common/paged-response.ts b/src/shared/common/paged-response.ts new file mode 100644 index 0000000..086aca4 --- /dev/null +++ b/src/shared/common/paged-response.ts @@ -0,0 +1,31 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PagedResponse { + @ApiProperty({ isArray: true }) + items: T[]; + + @ApiProperty({ example: 100 }) + total: number; + + @ApiProperty({ example: 1 }) + page: number; + + @ApiProperty({ example: 10 }) + size: number; + + constructor(items: T[], total: number, page: number, size: number) { + this.items = items; + this.total = total; + this.page = page; + this.size = size; + } + + static of( + items: T[], + total: number, + page: number, + size: number, + ): PagedResponse { + return new PagedResponse(items, total, page, size); + } +} From dcaa4d44ef26f11b0ef9b29942be3cb7c2a34236 Mon Sep 17 00:00:00 2001 From: stoneTiger0912 Date: Wed, 18 Mar 2026 14:26:31 +0900 Subject: [PATCH 2/6] =?UTF-8?q?Fix:=20=EC=98=A4=EB=A5=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/cardset-search.request.ts | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/src/cardset/application/dto/request/cardset-search.request.ts b/src/cardset/application/dto/request/cardset-search.request.ts index c336c13..407efe5 100644 --- a/src/cardset/application/dto/request/cardset-search.request.ts +++ b/src/cardset/application/dto/request/cardset-search.request.ts @@ -1,47 +1,29 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; export class CardsetSearchRequest { - @ApiPropertyOptional({ example: 1, description: '페이지 번호 (1부터 시작)' }) - @IsOptional() - @IsInt() - @Min(1) - @Type(() => Number) + @ApiPropertyOptional({ example: 1, description: '페이지 번호 (1부터 시작)', default: 1 }) page?: number = 1; - @ApiPropertyOptional({ example: 10, description: '페이지당 항목 수' }) - @IsOptional() - @IsInt() - @Min(1) - @Max(30) - @Type(() => Number) + @ApiPropertyOptional({ example: 10, description: '페이지당 항목 수 (최대 30)', default: 10 }) size?: number = 10; @ApiPropertyOptional({ example: 'createdAt', description: '정렬 기준 (createdAt, name, cardCount)', }) - @IsOptional() - @IsString() sortBy?: string; @ApiPropertyOptional({ example: 'desc', enum: ['asc', 'desc'], description: '정렬 방향', + default: 'desc', }) - @IsOptional() - @IsIn(['asc', 'desc', 'ASC', 'DESC']) order?: string = 'desc'; @ApiPropertyOptional({ example: '영어', description: '카드셋 이름 검색 키워드' }) - @IsOptional() - @IsString() keyword?: string; @ApiPropertyOptional({ example: '언어', description: '카테고리 필터' }) - @IsOptional() - @IsString() category?: string; } From 45a72fad68e76c4d416b3bf50d5d5b95e3df9bd9 Mon Sep 17 00:00:00 2001 From: stoneTiger0912 Date: Wed, 18 Mar 2026 14:31:44 +0900 Subject: [PATCH 3/6] =?UTF-8?q?Fix:=20=EC=98=A4=EB=A5=98=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 --- .../infrastructure/http/cardset.controller.ts | 88 +++++++++---------- 1 file changed, 42 insertions(+), 46 deletions(-) diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts index 0c9033a..c363b56 100644 --- a/src/cardset/infrastructure/http/cardset.controller.ts +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -6,8 +6,8 @@ import { Delete, Body, Param, + Query, Headers, - HttpCode, } from '@nestjs/common'; import { @@ -15,6 +15,7 @@ import { ApiOperation, ApiResponse as SwaggerApiResponse, ApiParam, + ApiQuery, ApiExtraModels, } from '@nestjs/swagger'; import { CardsetUseCase } from '../../application/cardset.use-case'; @@ -35,28 +36,6 @@ import { PagedResponse } from '../../../shared/common/paged-response'; export class CardsetController { constructor(private readonly cardsetUseCase: CardsetUseCase) {} - @Post('search') - @HttpCode(200) - @ApiOperation({ summary: '카드셋 전체 목록 조회 (페이징)' }) - @SwaggerApiResponse({ - status: 200, - description: '조회 성공', - }) - async search( - @Headers('X-USER-ID') userId: string, - @Body() dto: CardsetSearchRequest, - ): Promise>> { - const { items, total, page, size } = await this.cardsetUseCase.findAllPaged( - dto, - parseInt(userId), - ); - const responseItems = items.map( - ({ cardset, imageUrl, likeCount, bookmarkCount, liked, bookmarked, managers }) => - CardsetListItemResponse.from(cardset, imageUrl, liked, bookmarked, managers, likeCount, bookmarkCount), - ); - return ApiResponse.success(PagedResponse.of(responseItems, total, page, size)); - } - @Post() @ApiOperation({ summary: '카드셋 생성' }) @SwaggerApiResponse({ @@ -74,37 +53,54 @@ export class CardsetController { } @Get() - @ApiOperation({ summary: '카드셋 목록 조회' }) - @SwaggerApiResponse({ - status: 200, - description: '조회 성공', - type: [CardsetResponse], + @ApiOperation({ summary: '카드셋 목록 조회 (페이징)' }) + @ApiQuery({ name: 'page', required: false, example: 1 }) + @ApiQuery({ name: 'size', required: false, example: 10 }) + @ApiQuery({ + name: 'sortBy', + required: false, + example: 'createdAt', + description: 'createdAt | name | cardCount', }) + @ApiQuery({ + name: 'order', + required: false, + example: 'desc', + enum: ['asc', 'desc'], + }) + @ApiQuery({ name: 'keyword', required: false, example: '영어' }) + @ApiQuery({ name: 'category', required: false, example: '언어' }) + @SwaggerApiResponse({ status: 200, description: '조회 성공' }) async findAll( @Headers('X-USER-ID') userId: string, - ): Promise> { - const results = await this.cardsetUseCase.findAll(parseInt(userId)); - return ApiResponse.success( - results.map( - ({ + @Query() query: CardsetSearchRequest, + ): Promise>> { + const { items, total, page, size } = await this.cardsetUseCase.findAllPaged( + query, + parseInt(userId), + ); + const responseItems = items.map( + ({ + cardset, + imageUrl, + likeCount, + bookmarkCount, + liked, + bookmarked, + managers, + }) => + CardsetListItemResponse.from( cardset, imageUrl, - likeCount, - bookmarkCount, liked, bookmarked, managers, - }) => - CardsetResponse.from( - cardset, - imageUrl, - likeCount, - bookmarkCount, - liked, - bookmarked, - managers, - ), - ), + likeCount, + bookmarkCount, + ), + ); + return ApiResponse.success( + PagedResponse.of(responseItems, total, page, size), ); } From 6969e8429c5d30623d346c7305b82a2f5acee1ef Mon Sep 17 00:00:00 2001 From: stoneTiger0912 Date: Wed, 18 Mar 2026 14:36:17 +0900 Subject: [PATCH 4/6] =?UTF-8?q?Feat:=20=EC=A0=95=EB=A0=AC=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 --- .../infrastructure/http/cardset.controller.ts | 2 +- .../persistence/cardset.repository.impl.ts | 21 ++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts index c363b56..f69dda8 100644 --- a/src/cardset/infrastructure/http/cardset.controller.ts +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -60,7 +60,7 @@ export class CardsetController { name: 'sortBy', required: false, example: 'createdAt', - description: 'createdAt | name | cardCount', + description: 'id | like | book', }) @ApiQuery({ name: 'order', diff --git a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts index 0a8632d..85a89c2 100644 --- a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts +++ b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts @@ -28,14 +28,25 @@ export class CardsetRepositoryImpl implements ICardsetRepository { const { page, size, - sortBy = 'createdAt', + sortBy = 'id', order = 'DESC', keyword, category, } = options; + const metadataSortFields = new Set(['like', 'book']); + const needsMetadataJoin = metadataSortFields.has(sortBy); + const qb = this.ormRepository.createQueryBuilder('cs'); + if (needsMetadataJoin) { + qb.leftJoin( + 'card_set_metadata', + 'meta', + 'meta.card_set_id = cs.id', + ); + } + if (keyword) { qb.andWhere('cs.name LIKE :keyword', { keyword: `%${keyword}%` }); } @@ -44,11 +55,11 @@ export class CardsetRepositoryImpl implements ICardsetRepository { } const allowedSortFields: Record = { - createdAt: 'cs.createdAt', - name: 'cs.name', - cardCount: 'cs.cardCount', + id: 'cs.id', + like: 'COALESCE(meta.like_count, 0)', + book: 'COALESCE(meta.bookmark_count, 0)', }; - const sortField = allowedSortFields[sortBy] ?? 'cs.createdAt'; + const sortField = allowedSortFields[sortBy] ?? 'cs.id'; qb.orderBy(sortField, order) .skip(page * size) .take(size); From 5dddd9bffc678f050f7ecc4711f17fe8192a5468 Mon Sep 17 00:00:00 2001 From: stoneTiger0912 Date: Wed, 18 Mar 2026 14:48:14 +0900 Subject: [PATCH 5/6] =?UTF-8?q?Feat:=20=EA=B7=B8=EB=A3=B9=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=EC=85=8B=20=EC=A1=B0=ED=9A=8C=20API=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 --- src/cardset/application/cardset.use-case.ts | 101 +++++++++++++++++- .../domain/repository/cardset.repository.ts | 1 + .../infrastructure/http/cardset.controller.ts | 2 +- .../http/group-cardset.controller.ts | 45 +++++--- .../persistence/cardset.repository.impl.ts | 10 +- 5 files changed, 135 insertions(+), 24 deletions(-) diff --git a/src/cardset/application/cardset.use-case.ts b/src/cardset/application/cardset.use-case.ts index de3318a..6594b75 100644 --- a/src/cardset/application/cardset.use-case.ts +++ b/src/cardset/application/cardset.use-case.ts @@ -161,13 +161,27 @@ export class CardsetUseCase { const visibleCardsets: Cardset[] = []; for (const cardset of items) { - const canView = - cardset.visibility === Visibility.PUBLIC || - (await this.groupGrpcClient.isUserInGroup(cardset.groupId, userId)); - if (canView) visibleCardsets.push(cardset); + if (cardset.visibility === Visibility.PUBLIC) { + visibleCardsets.push(cardset); + continue; + } + try { + const inGroup = await this.groupGrpcClient.isUserInGroup( + cardset.groupId, + userId, + ); + if (inGroup) visibleCardsets.push(cardset); + } catch { + // 그룹 조회 실패 시 해당 카드셋 제외 + } } const ids = visibleCardsets.map((c) => c.id); + + if (ids.length === 0) { + return { items: [], total, page, size }; + } + const [metadataMap, likedMap, bookmarkedMap, managersMap] = await Promise.all([ this.metadataRepository.findByCardSetIds(ids), @@ -268,6 +282,85 @@ export class CardsetUseCase { return result; } + async findByGroupIdPaged( + groupId: number, + userId: number, + req: CardsetSearchRequest, + ): Promise<{ + items: { + cardset: Cardset; + imageUrl: string; + likeCount: number; + bookmarkCount: number; + liked: boolean; + bookmarked: boolean; + managers: UserInfo[]; + }[]; + total: number; + page: number; + size: number; + }> { + await this.groupGrpcClient.checkUserInGroup(groupId, userId); + + const page = req.page ?? 1; + const size = req.size ?? 10; + + const { items, total } = await this.cardsetRepository.findAllPaged({ + page: page - 1, + size, + sortBy: req.sortBy, + order: (req.order?.toUpperCase() as 'ASC' | 'DESC') ?? 'DESC', + keyword: req.keyword, + category: req.category, + groupId, + }); + + const ids = items.map((c) => c.id); + + if (ids.length === 0) { + return { items: [], total, page, size }; + } + + const [metadataMap, likedMap, bookmarkedMap, managersMap] = + await Promise.all([ + this.metadataRepository.findByCardSetIds(ids), + this.skipReactionGrpc + ? Promise.resolve(new Map()) + : this.reactionGrpcClient.areLiked(ids, userId), + this.skipReactionGrpc + ? Promise.resolve(new Map()) + : this.reactionGrpcClient.areBookmarked(ids, userId), + this.getManagersForCardSets(ids), + ]); + + const result: { + cardset: Cardset; + imageUrl: string; + likeCount: number; + bookmarkCount: number; + liked: boolean; + bookmarked: boolean; + managers: UserInfo[]; + }[] = []; + for (const cardset of items) { + const imageUrl = cardset.imageRefId + ? await this.imageGrpcClient.getImageUrl(cardset.id) + : this.defaultImageUrl; + const meta = metadataMap.get(cardset.id); + result.push({ + cardset, + imageUrl, + likeCount: meta?.likeCount ?? 0, + bookmarkCount: meta?.bookmarkCount ?? 0, + liked: likedMap.get(cardset.id) ?? false, + bookmarked: bookmarkedMap.get(cardset.id) ?? false, + managers: managersMap.get(cardset.id) ?? [], + }); + } + + return { items: result, total, page, size }; + } + async findOne( id: number, userId: number, diff --git a/src/cardset/domain/repository/cardset.repository.ts b/src/cardset/domain/repository/cardset.repository.ts index 2e25572..b0b5734 100644 --- a/src/cardset/domain/repository/cardset.repository.ts +++ b/src/cardset/domain/repository/cardset.repository.ts @@ -10,6 +10,7 @@ export type CardsetPageOptions = { order?: 'ASC' | 'DESC'; keyword?: string; category?: string; + groupId?: number; }; export type CardsetPageResult = { diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts index f69dda8..aff7b25 100644 --- a/src/cardset/infrastructure/http/cardset.controller.ts +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -59,7 +59,7 @@ export class CardsetController { @ApiQuery({ name: 'sortBy', required: false, - example: 'createdAt', + example: 'id', description: 'id | like | book', }) @ApiQuery({ diff --git a/src/cardset/infrastructure/http/group-cardset.controller.ts b/src/cardset/infrastructure/http/group-cardset.controller.ts index 8827567..666d966 100644 --- a/src/cardset/infrastructure/http/group-cardset.controller.ts +++ b/src/cardset/infrastructure/http/group-cardset.controller.ts @@ -1,15 +1,18 @@ -import { Controller, Get, Headers, Param } from '@nestjs/common'; +import { Controller, Get, Headers, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiParam, + ApiQuery, ApiExtraModels, } from '@nestjs/swagger'; import { CardsetUseCase } from '../../application/cardset.use-case'; +import { CardsetSearchRequest } from '../../application/dto/request/cardset-search.request'; import { CardsetListItemResponse } from '../../application/dto/response/cardset-list-item.response'; import { ManagerInfoResponse } from '../../application/dto/response/manager-info.response'; import { ApiResponse } from '../../../shared/common/api-response'; +import { PagedResponse } from '../../../shared/common/paged-response'; @ApiExtraModels(CardsetListItemResponse, ManagerInfoResponse) @ApiTags('groups') @@ -18,24 +21,38 @@ export class GroupCardsetController { constructor(private readonly cardsetUseCase: CardsetUseCase) {} @Get(':groupId/card-sets') - @ApiOperation({ summary: '그룹의 카드셋 목록 조회' }) + @ApiOperation({ summary: '그룹의 카드셋 목록 조회 (페이징)' }) @ApiParam({ name: 'groupId', type: Number }) - @SwaggerApiResponse({ - status: 200, - description: '조회 성공', - type: CardsetListItemResponse, - isArray: true, + @ApiQuery({ name: 'page', required: false, example: 1 }) + @ApiQuery({ name: 'size', required: false, example: 10 }) + @ApiQuery({ + name: 'sortBy', + required: false, + example: 'id', + description: 'id | like | book', }) + @ApiQuery({ + name: 'order', + required: false, + example: 'desc', + enum: ['asc', 'desc'], + }) + @ApiQuery({ name: 'keyword', required: false, example: '영어' }) + @ApiQuery({ name: 'category', required: false, example: '언어' }) + @SwaggerApiResponse({ status: 200, description: '조회 성공' }) @SwaggerApiResponse({ status: 403, description: '그룹 멤버 아님' }) async findByGroupId( @Headers('X-USER-ID') userId: string, @Param('groupId') groupId: string, - ): Promise> { - const items = await this.cardsetUseCase.findByGroupId( - parseInt(groupId), - parseInt(userId), - ); - const content = items.map( + @Query() query: CardsetSearchRequest, + ): Promise>> { + const { items, total, page, size } = + await this.cardsetUseCase.findByGroupIdPaged( + parseInt(groupId), + parseInt(userId), + query, + ); + const responseItems = items.map( ({ cardset, imageUrl, @@ -55,6 +72,6 @@ export class GroupCardsetController { bookmarkCount, ), ); - return ApiResponse.success(content); + return ApiResponse.success(PagedResponse.of(responseItems, total, page, size)); } } diff --git a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts index 85a89c2..6c16e02 100644 --- a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts +++ b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts @@ -32,6 +32,7 @@ export class CardsetRepositoryImpl implements ICardsetRepository { order = 'DESC', keyword, category, + groupId, } = options; const metadataSortFields = new Set(['like', 'book']); @@ -40,13 +41,12 @@ export class CardsetRepositoryImpl implements ICardsetRepository { const qb = this.ormRepository.createQueryBuilder('cs'); if (needsMetadataJoin) { - qb.leftJoin( - 'card_set_metadata', - 'meta', - 'meta.card_set_id = cs.id', - ); + qb.leftJoin('card_set_metadata', 'meta', 'meta.card_set_id = cs.id'); } + if (groupId !== undefined) { + qb.andWhere('cs.groupId = :groupId', { groupId }); + } if (keyword) { qb.andWhere('cs.name LIKE :keyword', { keyword: `%${keyword}%` }); } From 221023e93272c7c4925cdb395e3b4c5120349da0 Mon Sep 17 00:00:00 2001 From: stoneTiger0912 Date: Wed, 18 Mar 2026 14:52:45 +0900 Subject: [PATCH 6/6] =?UTF-8?q?Feat:=20=EC=B9=B4=EB=93=9C=EC=85=8B=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=8B=9C=20=ED=83=9C=EA=B7=B8=EB=8F=84=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/persistence/cardset.repository.impl.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts index 6c16e02..25111da 100644 --- a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts +++ b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts @@ -48,7 +48,10 @@ export class CardsetRepositoryImpl implements ICardsetRepository { qb.andWhere('cs.groupId = :groupId', { groupId }); } if (keyword) { - qb.andWhere('cs.name LIKE :keyword', { keyword: `%${keyword}%` }); + qb.andWhere( + '(cs.name LIKE :keyword OR cs.hashtag LIKE :keyword)', + { keyword: `%${keyword}%` }, + ); } if (category) { qb.andWhere('cs.category = :category', { category });