diff --git a/src/cardset/application/cardset.use-case.ts b/src/cardset/application/cardset.use-case.ts index 7373944..8a9bcff 100644 --- a/src/cardset/application/cardset.use-case.ts +++ b/src/cardset/application/cardset.use-case.ts @@ -16,6 +16,9 @@ import type { ICardSetMetadataRepository } from '../domain/repository/cardset-me import { CardsetCardDomainService } from '../domain/service/cardset-card.domain-service'; import { GroupGrpcClient } from '../infrastructure/grpc/group-grpc.client'; import { ImageGrpcClient } from '../infrastructure/grpc/image-grpc.client'; +import { ReactionGrpcClient } from '../infrastructure/grpc/reaction-grpc.client'; +import { UserGrpcClient } from '../infrastructure/grpc/user-grpc.client'; +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'; @@ -31,6 +34,8 @@ export class CardsetUseCase { private readonly cardsetCardDomainService: CardsetCardDomainService, private readonly groupGrpcClient: GroupGrpcClient, private readonly imageGrpcClient: ImageGrpcClient, + private readonly reactionGrpcClient: ReactionGrpcClient, + private readonly userGrpcClient: UserGrpcClient, private readonly dataSource: DataSource, @Inject(CARDSET_METADATA_REPOSITORY) private readonly metadataRepository: ICardSetMetadataRepository, @@ -53,25 +58,36 @@ export class CardsetUseCase { async create(userId: number, dto: CreateCardsetRequest): Promise { await this.groupGrpcClient.checkUserInGroup(dto.groupId, userId); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const additionalManagerIds: number[] = dto.managerIds ?? []; + for (const managerId of additionalManagerIds) { + await this.groupGrpcClient.checkUserInGroup(dto.groupId, managerId); + } + return this.dataSource.transaction(async (manager) => { const cardset = Cardset.create(dto); const savedCardset = await this.cardsetRepository.save(cardset, manager); - const cardCount = dto.cardCount ?? 10; const cardsToAdd = this.cardsetCardDomainService.buildCardsToAdd( savedCardset.id, 0, - cardCount, + 10, ); for (const card of cardsToAdd) { await this.cardRepository.save(card, manager); } - const cardsetManager = CardsetManager.create({ + const managerIds = [ userId, - cardSetId: savedCardset.id, - }); - await this.cardsetManagerRepository.save(cardsetManager, manager); + ...additionalManagerIds.filter((id) => id !== userId), + ]; + for (const managerId of managerIds) { + const cardsetManager = CardsetManager.create({ + userId: managerId, + cardSetId: savedCardset.id, + }); + await this.cardsetManagerRepository.save(cardsetManager, manager); + } if (dto.imageRefId) { await this.imageGrpcClient.activateImage( @@ -87,12 +103,40 @@ export class CardsetUseCase { private readonly defaultImageUrl = process.env.DEFAULT_CARDSET_IMAGE_URL ?? ''; + private readonly skipUserGrpc = process.env.SKIP_USER_GRPC === 'true'; + private readonly skipReactionGrpc = process.env.SKIP_REACTION_GRPC === 'true'; + + private async getManagersForCardSets( + cardSetIds: number[], + ): Promise> { + if (cardSetIds.length === 0 || this.skipUserGrpc) return new Map(); + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ + const managers: CardsetManager[] = + await this.cardsetManagerRepository.findByCardSetIds(cardSetIds); + const userIds: number[] = [...new Set(managers.map((m) => m.userId))]; + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ + const users = await this.userGrpcClient.getUsersByIds(userIds); + const userMap = new Map(users.map((u) => [Number(u.id), u])); + const result = new Map(); + for (const m of managers) { + const user = userMap.get(m.userId); + if (!user) continue; + const list = result.get(m.cardSetId) ?? []; + list.push(user); + result.set(m.cardSetId, list); + } + return result; + } + async findAll(userId: number): Promise< { cardset: Cardset; imageUrl: string; likeCount: number; bookmarkCount: number; + liked: boolean; + bookmarked: boolean; + managers: UserInfo[]; }[] > { const cardsets = await this.cardsetRepository.findAll(); @@ -104,15 +148,27 @@ export class CardsetUseCase { if (canView) visibleCardsets.push(cardset); } - const metadataMap = await this.metadataRepository.findByCardSetIds( - visibleCardsets.map((c) => c.id), - ); + 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 @@ -124,6 +180,9 @@ export class CardsetUseCase { 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 result; @@ -137,6 +196,9 @@ export class CardsetUseCase { imageUrl: string; likeCount: number; bookmarkCount: number; + liked: boolean; + bookmarked: boolean; + managers: UserInfo[]; } | null> { const cardset = await this.cardsetRepository.findById(id); if (!cardset) return null; @@ -148,18 +210,88 @@ export class CardsetUseCase { if (!inGroup) throw new BusinessException(ErrorCode.CARDSET_ACCESS_DENIED); } - const imageUrl = cardset.imageRefId - ? await this.imageGrpcClient.getImageUrl(cardset.id) - : this.defaultImageUrl; - const meta = await this.metadataRepository.findByCardSetId(id); + const [imageUrl, meta, liked, bookmarked, managersMap] = await Promise.all([ + cardset.imageRefId + ? this.imageGrpcClient.getImageUrl(cardset.id) + : Promise.resolve(this.defaultImageUrl), + this.metadataRepository.findByCardSetId(id), + this.skipReactionGrpc + ? Promise.resolve(false) + : this.reactionGrpcClient.isLiked(id, userId), + this.skipReactionGrpc + ? Promise.resolve(false) + : this.reactionGrpcClient.isBookmarked(id, userId), + this.getManagersForCardSets([id]), + ]); return { cardset, imageUrl, likeCount: meta?.likeCount ?? 0, bookmarkCount: meta?.bookmarkCount ?? 0, + liked, + bookmarked, + managers: managersMap.get(id) ?? [], }; } + async findByGroupId( + groupId: number, + userId: number, + ): Promise< + { + cardset: Cardset; + imageUrl: string; + likeCount: number; + bookmarkCount: number; + liked: boolean; + bookmarked: boolean; + managers: UserInfo[]; + }[] + > { + await this.groupGrpcClient.checkUserInGroup(groupId, userId); + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ + const cardsets: Cardset[] = + await this.cardsetRepository.findByGroupId(groupId); + const ids: number[] = cardsets.map((c) => c.id); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call */ + 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 cardsets) { + 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 result; + } + async update( id: number, userId: number, @@ -170,9 +302,33 @@ export class CardsetUseCase { await this.checkIsManager(id, userId); - return this.dataSource.transaction(async () => { - if (dto.imageRefId !== undefined) { - await this.imageGrpcClient.changeImage(dto.imageRefId, id); + if (dto.imageRefId !== undefined) { + await this.imageGrpcClient.changeImage(dto.imageRefId, id); + } + + return this.dataSource.transaction(async (manager) => { + if (dto.managerIds !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const newManagerIds: number[] = dto.managerIds; + for (const managerId of newManagerIds) { + await this.groupGrpcClient.checkUserInGroup( + cardset.groupId, + managerId, + ); + } + + const existing = + await this.cardsetManagerRepository.findAllByCardSetId(id); + for (const m of existing) { + await this.cardsetManagerRepository.delete(m.id); + } + for (const managerId of newManagerIds) { + const cardsetManager = CardsetManager.create({ + userId: managerId, + cardSetId: id, + }); + await this.cardsetManagerRepository.save(cardsetManager, manager); + } } return this.cardsetRepository.update(id, dto); diff --git a/src/cardset/application/dto/request/create-cardset.request.ts b/src/cardset/application/dto/request/create-cardset.request.ts index 39857af..9873eae 100644 --- a/src/cardset/application/dto/request/create-cardset.request.ts +++ b/src/cardset/application/dto/request/create-cardset.request.ts @@ -20,6 +20,6 @@ export class CreateCardsetRequest { @ApiPropertyOptional({ example: 1001 }) imageRefId?: number | null; - @ApiPropertyOptional({ example: 10 }) - cardCount?: number; + @ApiPropertyOptional({ example: [1, 2], type: [Number] }) + managerIds?: number[]; } diff --git a/src/cardset/application/dto/request/update-cardset.request.ts b/src/cardset/application/dto/request/update-cardset.request.ts index 0b6e3d9..5a124e8 100644 --- a/src/cardset/application/dto/request/update-cardset.request.ts +++ b/src/cardset/application/dto/request/update-cardset.request.ts @@ -16,4 +16,7 @@ export class UpdateCardsetRequest { @ApiPropertyOptional({ example: 1001 }) imageRefId?: number; + + @ApiPropertyOptional({ example: [1, 2], type: [Number] }) + managerIds?: number[]; } diff --git a/src/cardset/application/dto/response/cardset-list-item.response.ts b/src/cardset/application/dto/response/cardset-list-item.response.ts new file mode 100644 index 0000000..272a050 --- /dev/null +++ b/src/cardset/application/dto/response/cardset-list-item.response.ts @@ -0,0 +1,74 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Cardset } from '../../../domain/model/cardset'; +import { ManagerInfoResponse } from './manager-info.response'; +import type { UserInfo } from '../../../infrastructure/grpc/user-grpc.client'; + +export class CardsetListItemResponse { + @ApiProperty({ example: 1 }) + cardSetId!: number; + + @ApiProperty({ example: 1 }) + groupId!: number; + + @ApiProperty({ example: '영어 단어장' }) + name!: string; + + @ApiProperty({ example: '언어' }) + category!: string; + + @ApiPropertyOptional({ example: '#영어#단어' }) + hashtag!: string | null; + + @ApiProperty({ example: 'https://example.com/image.png' }) + imageUrl!: string; + + @ApiPropertyOptional({ example: 1001 }) + imageRefId!: number | null; + + @ApiProperty({ example: 0 }) + likeCount!: number; + + @ApiProperty({ example: 0 }) + bookmarkCount!: number; + + @ApiProperty({ example: false }) + liked!: boolean; + + @ApiProperty({ example: false }) + bookmarked!: boolean; + + @ApiProperty({ type: [ManagerInfoResponse] }) + managers!: ManagerInfoResponse[]; + + static from( + cardset: Cardset, + imageUrl = '', + liked = false, + bookmarked = false, + managers: UserInfo[] = [], + likeCount = 0, + bookmarkCount = 0, + ): CardsetListItemResponse { + const res = new CardsetListItemResponse(); + res.cardSetId = cardset.id; + res.groupId = cardset.groupId; + res.name = cardset.name; + res.category = cardset.category; + res.hashtag = cardset.hashtag; + res.imageUrl = imageUrl; + res.imageRefId = cardset.imageRefId; + res.likeCount = likeCount; + res.bookmarkCount = bookmarkCount; + res.liked = liked; + res.bookmarked = bookmarked; + res.managers = managers.map((m) => { + const info = new ManagerInfoResponse(); + info.id = m.id; + info.email = m.email; + info.nickname = m.nickname; + info.profileImageUrl = m.profileImageUrl; + return info; + }); + return res; + } +} diff --git a/src/cardset/application/dto/response/cardset.response.ts b/src/cardset/application/dto/response/cardset.response.ts index bf86454..e9f7e4d 100644 --- a/src/cardset/application/dto/response/cardset.response.ts +++ b/src/cardset/application/dto/response/cardset.response.ts @@ -1,6 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Visibility } from '../../../domain/model/visibility'; import { Cardset } from '../../../domain/model/cardset'; +import { ManagerInfoResponse } from './manager-info.response'; +import type { UserInfo } from '../../../infrastructure/grpc/user-grpc.client'; export class CardsetResponse { @ApiProperty({ example: 1 }) @@ -36,6 +38,15 @@ export class CardsetResponse { @ApiProperty({ example: 0 }) bookmarkCount!: number; + @ApiProperty({ example: false }) + liked!: boolean; + + @ApiProperty({ example: false }) + bookmarked!: boolean; + + @ApiProperty({ type: [ManagerInfoResponse] }) + managers!: ManagerInfoResponse[]; + @ApiProperty() createdAt!: Date; @@ -47,6 +58,9 @@ export class CardsetResponse { imageUrl = '', likeCount = 0, bookmarkCount = 0, + liked = false, + bookmarked = false, + managers: UserInfo[] = [], ): CardsetResponse { const res = new CardsetResponse(); res.id = cardset.id; @@ -60,6 +74,16 @@ export class CardsetResponse { res.cardCount = cardset.cardCount; res.likeCount = likeCount; res.bookmarkCount = bookmarkCount; + res.liked = liked; + res.bookmarked = bookmarked; + res.managers = managers.map((m) => { + const info = new ManagerInfoResponse(); + info.id = m.id; + info.email = m.email; + info.nickname = m.nickname; + info.profileImageUrl = m.profileImageUrl; + return info; + }); res.createdAt = cardset.createdAt; res.updatedAt = cardset.updatedAt; return res; diff --git a/src/cardset/application/dto/response/manager-info.response.ts b/src/cardset/application/dto/response/manager-info.response.ts new file mode 100644 index 0000000..b196dd9 --- /dev/null +++ b/src/cardset/application/dto/response/manager-info.response.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ManagerInfoResponse { + @ApiProperty({ example: 1 }) + id!: number; + + @ApiProperty({ example: 'user@example.com' }) + email!: string; + + @ApiProperty({ example: '닉네임' }) + nickname!: string; + + @ApiProperty({ example: 'https://example.com/profile.png' }) + profileImageUrl!: string; +} diff --git a/src/cardset/cardset.module.ts b/src/cardset/cardset.module.ts index b9a8701..1771f7d 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -23,9 +23,12 @@ import { CardUseCase } from './application/card.use-case'; import { CardsetController } from './infrastructure/http/cardset.controller'; import { CardController } from './infrastructure/http/card.controller'; +import { GroupCardsetController } from './infrastructure/http/group-cardset.controller'; import { CardsetGrpcController } from './infrastructure/grpc/cardset.grpc-controller'; import { GroupGrpcClient } from './infrastructure/grpc/group-grpc.client'; import { ImageGrpcClient } from './infrastructure/grpc/image-grpc.client'; +import { ReactionGrpcClient } from './infrastructure/grpc/reaction-grpc.client'; +import { UserGrpcClient } from './infrastructure/grpc/user-grpc.client'; import { GrpcClientModule } from '../shared/grpc/grpc-client.module'; @Module({ @@ -38,7 +41,7 @@ import { GrpcClientModule } from '../shared/grpc/grpc-client.module'; ]), GrpcClientModule, ], - controllers: [CardsetController, CardController, CardsetGrpcController], + controllers: [CardsetController, CardController, GroupCardsetController, CardsetGrpcController], providers: [ { provide: CARDSET_REPOSITORY, useClass: CardsetRepositoryImpl }, { provide: CARD_REPOSITORY, useClass: CardRepositoryImpl }, @@ -53,6 +56,8 @@ import { GrpcClientModule } from '../shared/grpc/grpc-client.module'; CardsetCardDomainService, GroupGrpcClient, ImageGrpcClient, + ReactionGrpcClient, + UserGrpcClient, CardsetUseCase, CardUseCase, ], diff --git a/src/cardset/domain/repository/cardset-manager.repository.ts b/src/cardset/domain/repository/cardset-manager.repository.ts index ed313c4..6773b76 100644 --- a/src/cardset/domain/repository/cardset-manager.repository.ts +++ b/src/cardset/domain/repository/cardset-manager.repository.ts @@ -13,5 +13,6 @@ export interface ICardsetManagerRepository { cardSetId: number, ): Promise; findAllByCardSetId(cardSetId: number): Promise; + findByCardSetIds(cardSetIds: number[]): Promise; delete(id: number): Promise; } diff --git a/src/cardset/domain/repository/cardset.repository.ts b/src/cardset/domain/repository/cardset.repository.ts index 743e0b2..2e25572 100644 --- a/src/cardset/domain/repository/cardset.repository.ts +++ b/src/cardset/domain/repository/cardset.repository.ts @@ -3,8 +3,24 @@ import { Cardset } from '../model/cardset'; export const CARDSET_REPOSITORY = Symbol('CARDSET_REPOSITORY'); +export type CardsetPageOptions = { + page: number; + size: number; + sortBy?: string; + order?: 'ASC' | 'DESC'; + keyword?: string; + category?: string; +}; + +export type CardsetPageResult = { + items: Cardset[]; + total: number; +}; + export interface ICardsetRepository { findAll(): Promise; + findAllPaged(options: CardsetPageOptions): Promise; + findByGroupId(groupId: number): Promise; findById(id: number): Promise; findByIds(ids: number[]): Promise; save(cardset: Cardset, manager?: EntityManager): Promise; diff --git a/src/cardset/infrastructure/grpc/group-grpc.client.ts b/src/cardset/infrastructure/grpc/group-grpc.client.ts index 4577d0c..8b9ccdb 100644 --- a/src/cardset/infrastructure/grpc/group-grpc.client.ts +++ b/src/cardset/infrastructure/grpc/group-grpc.client.ts @@ -13,7 +13,7 @@ interface GroupCommandService { @Injectable() export class GroupGrpcClient implements OnModuleInit { - private groupService: GroupCommandService; + private groupService!: GroupCommandService; constructor( @Inject('GROUP_GRPC_CLIENT') private readonly client: ClientGrpc, diff --git a/src/cardset/infrastructure/grpc/image-grpc.client.ts b/src/cardset/infrastructure/grpc/image-grpc.client.ts index 34b780b..239f4fe 100644 --- a/src/cardset/infrastructure/grpc/image-grpc.client.ts +++ b/src/cardset/infrastructure/grpc/image-grpc.client.ts @@ -29,7 +29,7 @@ interface ImageCommandService { @Injectable() export class ImageGrpcClient implements OnModuleInit { - private imageService: ImageCommandService; + private imageService!: ImageCommandService; constructor( @Inject('IMAGE_GRPC_CLIENT') private readonly client: ClientGrpc, diff --git a/src/cardset/infrastructure/grpc/reaction-grpc.client.ts b/src/cardset/infrastructure/grpc/reaction-grpc.client.ts new file mode 100644 index 0000000..cfd05bb --- /dev/null +++ b/src/cardset/infrastructure/grpc/reaction-grpc.client.ts @@ -0,0 +1,90 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import type { ClientGrpc } from '@nestjs/microservices'; +import { Observable, firstValueFrom } from 'rxjs'; + +interface ReactionService { + isLiked(data: { + targetType: string; + targetId: number; + userId: number; + }): Observable<{ reacted: boolean }>; + isBookmarked(data: { + targetType: string; + targetId: number; + userId: number; + }): Observable<{ reacted: boolean }>; + areLiked(data: { + targetType: string; + targetIds: number[]; + userId: number; + }): Observable<{ results: Record }>; + areBookmarked(data: { + targetType: string; + targetIds: number[]; + userId: number; + }): Observable<{ results: Record }>; +} + +@Injectable() +export class ReactionGrpcClient implements OnModuleInit { + private reactionService!: ReactionService; + + constructor( + @Inject('REACTION_GRPC_CLIENT') private readonly client: ClientGrpc, + ) {} + + onModuleInit() { + this.reactionService = + this.client.getService('ReactionService'); + } + + async isLiked(cardSetId: number, userId: number): Promise { + const result = await firstValueFrom( + this.reactionService.isLiked({ + targetType: 'CARD_SET', + targetId: cardSetId, + userId, + }), + ); + return result.reacted; + } + + async isBookmarked(cardSetId: number, userId: number): Promise { + const result = await firstValueFrom( + this.reactionService.isBookmarked({ + targetType: 'CARD_SET', + targetId: cardSetId, + userId, + }), + ); + return result.reacted; + } + + async areLiked( + cardSetIds: number[], + userId: number, + ): Promise> { + const result = await firstValueFrom( + this.reactionService.areLiked({ + targetType: 'CARD_SET', + targetIds: cardSetIds, + userId, + }), + ); + return new Map(Object.entries(result.results).map(([k, v]) => [Number(k), v])); + } + + async areBookmarked( + cardSetIds: number[], + userId: number, + ): Promise> { + const result = await firstValueFrom( + this.reactionService.areBookmarked({ + targetType: 'CARD_SET', + targetIds: cardSetIds, + userId, + }), + ); + return new Map(Object.entries(result.results).map(([k, v]) => [Number(k), v])); + } +} diff --git a/src/cardset/infrastructure/grpc/user-grpc.client.ts b/src/cardset/infrastructure/grpc/user-grpc.client.ts new file mode 100644 index 0000000..2d5c137 --- /dev/null +++ b/src/cardset/infrastructure/grpc/user-grpc.client.ts @@ -0,0 +1,38 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import type { ClientGrpc } from '@nestjs/microservices'; +import { Observable, firstValueFrom } from 'rxjs'; + +export interface UserInfo { + id: number; + email: string; + nickname: string; + profileImageUrl: string; +} + +interface UserQueryService { + getUsers(data: { + userIds: number[]; + }): Observable<{ users: UserInfo[] }>; +} + +@Injectable() +export class UserGrpcClient implements OnModuleInit { + private userService!: UserQueryService; + + constructor( + @Inject('USER_GRPC_CLIENT') private readonly client: ClientGrpc, + ) {} + + onModuleInit() { + this.userService = + this.client.getService('UserQueryService'); + } + + async getUsersByIds(userIds: number[]): Promise { + if (userIds.length === 0) return []; + const result = await firstValueFrom( + this.userService.getUsers({ userIds }), + ); + return result.users; + } +} diff --git a/src/cardset/infrastructure/http/card.controller.ts b/src/cardset/infrastructure/http/card.controller.ts index ba786ad..4ddbf0b 100644 --- a/src/cardset/infrastructure/http/card.controller.ts +++ b/src/cardset/infrastructure/http/card.controller.ts @@ -1,118 +1,98 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - Headers, -} from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiResponse as SwaggerApiResponse, - ApiParam, -} from '@nestjs/swagger'; +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { CardUseCase } from '../../application/card.use-case'; -import { CreateCardRequest } from '../../application/dto/request/create-card.request'; -import { UpdateCardRequest } from '../../application/dto/request/update-card.request'; -import { ReorderCardsRequest } from '../../application/dto/request/reorder-cards.request'; -import { CardCreateResponse } from '../../application/dto/response/card-create.response'; -import { CardResponse } from '../../application/dto/response/card.response'; -import { ApiResponse } from '../../../shared/common/api-response'; @ApiTags('cards') @Controller('cards') export class CardController { - constructor(private readonly cardUseCase: CardUseCase) {} + constructor(private readonly cardUseCase: CardUseCase) { } - @Post() - @ApiOperation({ summary: '카드 생성' }) - @SwaggerApiResponse({ - status: 201, - description: '생성 성공', - type: CardCreateResponse, - }) - async create( - @Headers('X-USER-ID') _userId: string, - @Body() dto: CreateCardRequest, - ): Promise> { - const card = await this.cardUseCase.create(dto); - return ApiResponse.created(CardCreateResponse.from(card.id)); - } + // @Post() + // @ApiOperation({ summary: '카드 생성' }) + // @SwaggerApiResponse({ + // status: 201, + // description: '생성 성공', + // type: CardCreateResponse, + // }) + // async create( + // @Headers('X-USER-ID') _userId: string, + // @Body() dto: CreateCardRequest, + // ): Promise> { + // const card = await this.cardUseCase.create(dto); + // return ApiResponse.created(CardCreateResponse.from(card.id)); + // } - @Get('cardset/:cardsetId') - @ApiOperation({ summary: '카드셋의 카드 목록 조회' }) - @ApiParam({ name: 'cardsetId', type: Number }) - @SwaggerApiResponse({ - status: 200, - description: '조회 성공', - type: [CardResponse], - }) - async findByCardsetId( - @Headers('X-USER-ID') _userId: string, - @Param('cardsetId') cardsetId: string, - ): Promise> { - const cards = await this.cardUseCase.findAllByCardsetId( - parseInt(cardsetId), - ); - return ApiResponse.success(cards.map((c) => CardResponse.from(c))); - } + // @Get('cardset/:cardsetId') + // @ApiOperation({ summary: '카드셋의 카드 목록 조회' }) + // @ApiParam({ name: 'cardsetId', type: Number }) + // @SwaggerApiResponse({ + // status: 200, + // description: '조회 성공', + // type: [CardResponse], + // }) + // async findByCardsetId( + // @Headers('X-USER-ID') _userId: string, + // @Param('cardsetId') cardsetId: string, + // ): Promise> { + // const cards = await this.cardUseCase.findAllByCardsetId( + // parseInt(cardsetId), + // ); + // return ApiResponse.success(cards.map((c) => CardResponse.from(c))); + // } - @Get(':cardId') - @ApiOperation({ summary: '카드 단건 조회' }) - @ApiParam({ name: 'cardId', type: Number }) - @SwaggerApiResponse({ - status: 200, - description: '조회 성공', - type: CardResponse, - }) - async findOne( - @Headers('X-USER-ID') _userId: string, - @Param('cardId') cardId: string, - ): Promise> { - const card = await this.cardUseCase.findOne(parseInt(cardId)); - return ApiResponse.success(card ? CardResponse.from(card) : null); - } + // @Get(':cardId') + // @ApiOperation({ summary: '카드 단건 조회' }) + // @ApiParam({ name: 'cardId', type: Number }) + // @SwaggerApiResponse({ + // status: 200, + // description: '조회 성공', + // type: CardResponse, + // }) + // async findOne( + // @Headers('X-USER-ID') _userId: string, + // @Param('cardId') cardId: string, + // ): Promise> { + // const card = await this.cardUseCase.findOne(parseInt(cardId)); + // return ApiResponse.success(card ? CardResponse.from(card) : null); + // } - @Put('reorder') - @ApiOperation({ summary: '카드 순서 변경' }) - @SwaggerApiResponse({ status: 200, description: '순서 변경 성공' }) - async reorderCards( - @Headers('X-USER-ID') _userId: string, - @Body() dto: ReorderCardsRequest, - ): Promise> { - await this.cardUseCase.reorderCards(dto.cardOrders); - return ApiResponse.success(null); - } + // @Put('reorder') + // @ApiOperation({ summary: '카드 순서 변경' }) + // @SwaggerApiResponse({ status: 200, description: '순서 변경 성공' }) + // async reorderCards( + // @Headers('X-USER-ID') _userId: string, + // @Body() dto: ReorderCardsRequest, + // ): Promise> { + // await this.cardUseCase.reorderCards(dto.cardOrders); + // return ApiResponse.success(null); + // } - @Put(':cardId') - @ApiOperation({ summary: '카드 수정' }) - @ApiParam({ name: 'cardId', type: Number }) - @SwaggerApiResponse({ - status: 200, - description: '수정 성공', - type: CardResponse, - }) - async update( - @Headers('X-USER-ID') _userId: string, - @Param('cardId') cardId: string, - @Body() dto: UpdateCardRequest, - ): Promise> { - const card = await this.cardUseCase.update(parseInt(cardId), dto); - return ApiResponse.success(card ? CardResponse.from(card) : null); - } + // @Put(':cardId') + // @ApiOperation({ summary: '카드 수정' }) + // @ApiParam({ name: 'cardId', type: Number }) + // @SwaggerApiResponse({ + // status: 200, + // description: '수정 성공', + // type: CardResponse, + // }) + // async update( + // @Headers('X-USER-ID') _userId: string, + // @Param('cardId') cardId: string, + // @Body() dto: UpdateCardRequest, + // ): Promise> { + // const card = await this.cardUseCase.update(parseInt(cardId), dto); + // return ApiResponse.success(card ? CardResponse.from(card) : null); + // } - @Delete(':cardId') - @ApiOperation({ summary: '카드 삭제' }) - @ApiParam({ name: 'cardId', type: Number }) - @SwaggerApiResponse({ status: 200, description: '삭제 성공' }) - async remove( - @Headers('X-USER-ID') _userId: string, - @Param('cardId') cardId: string, - ): Promise> { - await this.cardUseCase.remove(parseInt(cardId)); - return ApiResponse.success(null, '삭제되었습니다.'); - } + // @Delete(':cardId') + // @ApiOperation({ summary: '카드 삭제' }) + // @ApiParam({ name: 'cardId', type: Number }) + // @SwaggerApiResponse({ status: 200, description: '삭제 성공' }) + // async remove( + // @Headers('X-USER-ID') _userId: string, + // @Param('cardId') cardId: string, + // ): Promise> { + // await this.cardUseCase.remove(parseInt(cardId)); + // return ApiResponse.success(null, '삭제되었습니다.'); + // } } diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts index 5887eb7..439dda0 100644 --- a/src/cardset/infrastructure/http/cardset.controller.ts +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -13,18 +13,21 @@ import { ApiOperation, ApiResponse as SwaggerApiResponse, ApiParam, + ApiExtraModels, } from '@nestjs/swagger'; import { CardsetUseCase } from '../../application/cardset.use-case'; 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 { CardsetResponse } from '../../application/dto/response/cardset.response'; +import { ManagerInfoResponse } from '../../application/dto/response/manager-info.response'; import { ApiResponse } from '../../../shared/common/api-response'; +@ApiExtraModels(CardsetResponse, ManagerInfoResponse) @ApiTags('card-sets') @Controller('card-sets') export class CardsetController { - constructor(private readonly cardsetUseCase: CardsetUseCase) {} + constructor(private readonly cardsetUseCase: CardsetUseCase) { } @Post() @ApiOperation({ summary: '카드셋 생성' }) @@ -54,8 +57,25 @@ export class CardsetController { ): Promise> { const results = await this.cardsetUseCase.findAll(parseInt(userId)); return ApiResponse.success( - results.map(({ cardset, imageUrl, likeCount, bookmarkCount }) => - CardsetResponse.from(cardset, imageUrl, likeCount, bookmarkCount), + results.map( + ({ + cardset, + imageUrl, + likeCount, + bookmarkCount, + liked, + bookmarked, + managers, + }) => + CardsetResponse.from( + cardset, + imageUrl, + likeCount, + bookmarkCount, + liked, + bookmarked, + managers, + ), ), ); } @@ -84,6 +104,9 @@ export class CardsetController { result.imageUrl, result.likeCount, result.bookmarkCount, + result.liked, + result.bookmarked, + result.managers, ) : null, ); @@ -124,25 +147,25 @@ export class CardsetController { return ApiResponse.success(null, '삭제되었습니다.'); } - @Put(':cardsetId/card-count') - @ApiOperation({ summary: '카드 수 업데이트' }) - @ApiParam({ name: 'cardsetId', type: Number }) - @SwaggerApiResponse({ - status: 200, - description: '업데이트 성공', - type: CardsetResponse, - }) - @SwaggerApiResponse({ status: 403, description: '매니저 권한 없음' }) - async updateCardCount( - @Headers('X-USER-ID') userId: string, - @Param('cardsetId') cardsetId: string, - @Body() body: { cardCount: number }, - ): Promise> { - const cardset = await this.cardsetUseCase.updateCardCount( - parseInt(cardsetId), - parseInt(userId), - body.cardCount, - ); - return ApiResponse.success(cardset ? CardsetResponse.from(cardset) : null); - } + // @Put(':cardsetId/card-count') + // @ApiOperation({ summary: '카드 수 업데이트' }) + // @ApiParam({ name: 'cardsetId', type: Number }) + // @SwaggerApiResponse({ + // status: 200, + // description: '업데이트 성공', + // type: CardsetResponse, + // }) + // @SwaggerApiResponse({ status: 403, description: '매니저 권한 없음' }) + // async updateCardCount( + // @Headers('X-USER-ID') userId: string, + // @Param('cardsetId') cardsetId: string, + // @Body() body: { cardCount: number }, + // ): Promise> { + // const cardset = await this.cardsetUseCase.updateCardCount( + // parseInt(cardsetId), + // parseInt(userId), + // body.cardCount, + // ); + // return ApiResponse.success(cardset ? CardsetResponse.from(cardset) : null); + // } } diff --git a/src/cardset/infrastructure/http/group-cardset.controller.ts b/src/cardset/infrastructure/http/group-cardset.controller.ts new file mode 100644 index 0000000..82536fb --- /dev/null +++ b/src/cardset/infrastructure/http/group-cardset.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Headers, Param } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse as SwaggerApiResponse, + ApiParam, + ApiExtraModels, +} from '@nestjs/swagger'; +import { CardsetUseCase } from '../../application/cardset.use-case'; +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'; + +@ApiExtraModels(CardsetListItemResponse, ManagerInfoResponse) +@ApiTags('groups') +@Controller('groups') +export class GroupCardsetController { + constructor(private readonly cardsetUseCase: CardsetUseCase) {} + + @Get(':groupId/card-sets') + @ApiOperation({ summary: '그룹의 카드셋 목록 조회' }) + @ApiParam({ name: 'groupId', type: Number }) + @SwaggerApiResponse({ + status: 200, + description: '조회 성공', + type: CardsetListItemResponse, + isArray: true, + }) + @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( + ({ cardset, imageUrl, liked, bookmarked, managers, likeCount, bookmarkCount }) => + CardsetListItemResponse.from( + cardset, + imageUrl, + liked, + bookmarked, + managers, + likeCount, + bookmarkCount, + ), + ); + return ApiResponse.success(content); + } +} diff --git a/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts index d93d8e8..003d204 100644 --- a/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts +++ b/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts @@ -41,6 +41,15 @@ export class CardsetManagerRepositoryImpl implements ICardsetManagerRepository { return orms.map((orm) => CardsetManagerMapper.toDomain(orm)); } + async findByCardSetIds(cardSetIds: number[]): Promise { + if (cardSetIds.length === 0) return []; + const orms = await this.ormRepository + .createQueryBuilder('m') + .where('m.cardSetId IN (:...cardSetIds)', { cardSetIds }) + .getMany(); + return orms.map((orm) => CardsetManagerMapper.toDomain(orm)); + } + async delete(id: number): Promise { await this.ormRepository.delete(id); } diff --git a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts index 1884234..360cf85 100644 --- a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts +++ b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts @@ -1,7 +1,11 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EntityManager, Repository } from 'typeorm'; -import { ICardsetRepository } from '../../domain/repository/cardset.repository'; +import type { + ICardsetRepository, + CardsetPageOptions, + CardsetPageResult, +} from '../../domain/repository/cardset.repository'; import { Cardset } from '../../domain/model/cardset'; import { CardsetOrmEntity } from './orm/cardset.orm-entity'; import { CardsetMapper } from './mapper/cardset.mapper'; @@ -20,6 +24,38 @@ export class CardsetRepositoryImpl implements ICardsetRepository { return orms.map((orm) => CardsetMapper.toDomain(orm)); } + async findAllPaged(options: CardsetPageOptions): Promise { + const { page, size, sortBy = 'createdAt', order = 'DESC', keyword, category } = options; + + const qb = this.ormRepository.createQueryBuilder('cs'); + + if (keyword) { + qb.andWhere('cs.name LIKE :keyword', { keyword: `%${keyword}%` }); + } + if (category) { + qb.andWhere('cs.category = :category', { category }); + } + + const allowedSortFields: Record = { + createdAt: 'cs.createdAt', + name: 'cs.name', + cardCount: 'cs.cardCount', + }; + const sortField = allowedSortFields[sortBy] ?? 'cs.createdAt'; + qb.orderBy(sortField, order).skip(page * size).take(size); + + const [orms, total] = await qb.getManyAndCount(); + return { items: orms.map((orm) => CardsetMapper.toDomain(orm)), total }; + } + + async findByGroupId(groupId: number): Promise { + const orms = await this.ormRepository.find({ + where: { groupId }, + order: { createdAt: 'DESC' }, + }); + return orms.map((orm) => CardsetMapper.toDomain(orm)); + } + async findById(id: number): Promise { const orm = await this.ormRepository.findOne({ where: { id } }); return orm ? CardsetMapper.toDomain(orm) : null; diff --git a/src/proto/reaction.proto b/src/proto/reaction.proto new file mode 100644 index 0000000..2b6fc45 --- /dev/null +++ b/src/proto/reaction.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; +package reaction; + +service ReactionService { + rpc IsLiked (IsReactedRequest) returns (IsReactedResponse); + rpc AreLiked (AreReactedRequest) returns (AreReactedResponse); + rpc IsBookmarked (IsReactedRequest) returns (IsReactedResponse); + rpc AreBookmarked (AreReactedRequest) returns (AreReactedResponse); +} + +message IsReactedRequest { + string target_type = 1; + int64 target_id = 2; + int64 user_id = 3; +} +message IsReactedResponse { bool reacted = 1; } + +message AreReactedRequest { + string target_type = 1; + repeated int64 target_ids = 2; + int64 user_id = 3; +} +message AreReactedResponse { map results = 1; } \ No newline at end of file diff --git a/src/shared/grpc/grpc-client.module.ts b/src/shared/grpc/grpc-client.module.ts index c9cd65f..fe05edd 100644 --- a/src/shared/grpc/grpc-client.module.ts +++ b/src/shared/grpc/grpc-client.module.ts @@ -11,7 +11,8 @@ import { join } from 'path'; options: { package: 'group.v1', protoPath: join(__dirname, '../../proto/group.proto'), - url: process.env.GRPC_GROUP_URL ?? 'localhost:9094', + url: process.env.GROUP_GRPC_URL ?? 'localhost:9094', + loader: { longs: Number }, }, }, { @@ -20,7 +21,8 @@ import { join } from 'path'; options: { package: 'image.v1', protoPath: join(__dirname, '../../proto/image.proto'), - url: process.env.GRPC_IMAGE_URL ?? 'localhost:9092', + url: process.env.IMAGE_GRPC_URL ?? 'localhost:9092', + loader: { longs: Number }, }, }, { @@ -29,7 +31,18 @@ import { join } from 'path'; options: { package: 'user_query', protoPath: join(__dirname, '../../proto/user.proto'), - url: process.env.GRPC_USER_URL ?? 'localhost:9091', + url: process.env.USER_GRPC_URL ?? 'localhost:9091', + loader: { longs: Number }, + }, + }, + { + name: 'REACTION_GRPC_CLIENT', + transport: Transport.GRPC, + options: { + package: 'reaction', + protoPath: join(__dirname, '../../proto/reaction.proto'), + url: process.env.GRPC_REACTION_URL ?? 'localhost:9093', + loader: { longs: Number }, }, }, ]),