diff --git a/src/cardset/application/cardset.use-case.ts b/src/cardset/application/cardset.use-case.ts index d85714d..6594b75 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,98 @@ 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) { + 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), + 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; @@ -189,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/application/dto/request/cardset-search.request.ts b/src/cardset/application/dto/request/cardset-search.request.ts new file mode 100644 index 0000000..407efe5 --- /dev/null +++ b/src/cardset/application/dto/request/cardset-search.request.ts @@ -0,0 +1,29 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class CardsetSearchRequest { + @ApiPropertyOptional({ example: 1, description: '페이지 번호 (1부터 시작)', default: 1 }) + page?: number = 1; + + @ApiPropertyOptional({ example: 10, description: '페이지당 항목 수 (최대 30)', default: 10 }) + size?: number = 10; + + @ApiPropertyOptional({ + example: 'createdAt', + description: '정렬 기준 (createdAt, name, cardCount)', + }) + sortBy?: string; + + @ApiPropertyOptional({ + example: 'desc', + enum: ['asc', 'desc'], + description: '정렬 방향', + default: 'desc', + }) + order?: string = 'desc'; + + @ApiPropertyOptional({ example: '영어', description: '카드셋 이름 검색 키워드' }) + keyword?: string; + + @ApiPropertyOptional({ example: '언어', description: '카테고리 필터' }) + category?: string; +} 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 9752ba7..aff7b25 100644 --- a/src/cardset/infrastructure/http/cardset.controller.ts +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + Query, Headers, } from '@nestjs/common'; @@ -14,16 +15,20 @@ import { 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 { 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') @@ -48,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: '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: '조회 성공' }) 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), ); } 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 0a8632d..25111da 100644 --- a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts +++ b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts @@ -28,27 +28,41 @@ export class CardsetRepositoryImpl implements ICardsetRepository { const { page, size, - sortBy = 'createdAt', + sortBy = 'id', order = 'DESC', keyword, category, + groupId, } = 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 (groupId !== undefined) { + 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 }); } 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); 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); + } +}