From f794a65b637dfc0c9a19a6ad3a9e0ec2973d6390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 23 Oct 2025 00:50:51 +0900 Subject: [PATCH 01/11] =?UTF-8?q?Feat:=20DB=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 12 ++-- src/card/card.controller.ts | 37 +++++++++++- src/card/card.module.ts | 6 +- src/card/card.service.ts | 41 ++++++++++++- src/card/entities/card.entity.ts | 34 ++++++----- src/cardset/cardset.controller.ts | 40 ++++++++++++- src/cardset/cardset.module.ts | 7 ++- src/cardset/cardset.service.ts | 79 +++++++++++++++++++++++++- src/cardset/entities/cardset.entity.ts | 10 +++- src/common/entities/base.entity.ts | 12 ++++ 10 files changed, 243 insertions(+), 35 deletions(-) create mode 100644 src/common/entities/base.entity.ts diff --git a/src/app.module.ts b/src/app.module.ts index 17d7c3e..fd55b4e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,12 +3,11 @@ import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from './auth/auth.module'; import { CardsetModule } from './cardset/cardset.module'; -import { CardsetManagerModule } from './cardset-manager/cardset-manager.module'; -import { Cardset as CardSet } from './cardset/entities/cardset.entity'; -import { CardsetManager as CardSetManager } from './cardset-manager/entities/cardset-manager.entity'; import { CardModule } from './card/card.module'; -import { Card as Card } from './card/entities/card.entity'; +import { Cardset } from './cardset/entities/cardset.entity'; +import { Card } from './card/entities/card.entity'; import { WebSocketModule } from './websocket/websocket.module'; + @Module({ imports: [ AuthModule, @@ -20,11 +19,10 @@ import { WebSocketModule } from './websocket/websocket.module'; username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, - entities: [CardSet, CardSetManager, Card], - synchronize: false, + entities: [Cardset, Card], + synchronize: true, // 테이블 자동 생성 }), CardsetModule, - CardsetManagerModule, CardModule, WebSocketModule, ], diff --git a/src/card/card.controller.ts b/src/card/card.controller.ts index 0932aa8..2ad3bf8 100644 --- a/src/card/card.controller.ts +++ b/src/card/card.controller.ts @@ -1,7 +1,38 @@ -import { Controller } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common'; import { CardService } from './card.service'; +import { Card } from './entities/card.entity'; -@Controller('card') +@Controller('cards') export class CardController { constructor(private readonly cardService: CardService) {} -} + + @Post() + async create(@Body() cardData: Partial): Promise { + return await this.cardService.create(cardData); + } + + @Get('cardset/:cardsetId') + async findByCardsetId(@Param('cardsetId') cardsetId: string): Promise { + return await this.cardService.findAllByCardsetId(parseInt(cardsetId)); + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise { + return await this.cardService.findOne(parseInt(id)); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() updateData: Partial): Promise { + return await this.cardService.update(parseInt(id), updateData); + } + + @Delete(':id') + async remove(@Param('id') id: string): Promise { + return await this.cardService.remove(parseInt(id)); + } + + @Put('reorder') + async reorderCards(@Body() cardOrders: { cardId: number; order: number }[]): Promise { + return await this.cardService.reorderCards(cardOrders); + } +} \ No newline at end of file diff --git a/src/card/card.module.ts b/src/card/card.module.ts index 579e0cf..291f582 100644 --- a/src/card/card.module.ts +++ b/src/card/card.module.ts @@ -1,9 +1,13 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Card } from './entities/card.entity'; import { CardService } from './card.service'; import { CardController } from './card.controller'; @Module({ + imports: [TypeOrmModule.forFeature([Card])], controllers: [CardController], providers: [CardService], + exports: [CardService], }) -export class CardModule {} +export class CardModule {} \ No newline at end of file diff --git a/src/card/card.service.ts b/src/card/card.service.ts index 5f7ecb9..6ac9bfe 100644 --- a/src/card/card.service.ts +++ b/src/card/card.service.ts @@ -1,4 +1,43 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Card } from './entities/card.entity'; @Injectable() -export class CardService {} +export class CardService { + constructor( + @InjectRepository(Card) + private cardRepository: Repository, + ) {} + + async create(cardData: Partial): Promise { + const card = this.cardRepository.create(cardData); + return await this.cardRepository.save(card); + } + + async findAllByCardsetId(cardsetId: number): Promise { + return await this.cardRepository.find({ + where: { cardsetId }, + order: { order: 'ASC' }, + }); + } + + async findOne(id: number): Promise { + return await this.cardRepository.findOne({ where: { id } }); + } + + async update(id: number, updateData: Partial): Promise { + await this.cardRepository.update(id, updateData); + return await this.findOne(id); + } + + async remove(id: number): Promise { + await this.cardRepository.delete(id); + } + + async reorderCards(cardOrders: { cardId: number; order: number }[]): Promise { + for (const { cardId, order } of cardOrders) { + await this.cardRepository.update(cardId, { order }); + } + } +} \ No newline at end of file diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts index 4eb1b2e..3acf3f7 100644 --- a/src/card/entities/card.entity.ts +++ b/src/card/entities/card.entity.ts @@ -1,24 +1,28 @@ -import { Cardset } from '../../cardset/entities/cardset.entity'; import { - Column, Entity, - ManyToOne, PrimaryGeneratedColumn, + Column, + ManyToOne, JoinColumn, } from 'typeorm'; +import { Cardset } from '../../cardset/entities/cardset.entity'; +import { BaseEntity } from '../../common/entities/base.entity'; + +@Entity('cards') +export class Card extends BaseEntity { + @PrimaryGeneratedColumn() + id: number; -@Entity('card') -export class Card { - @PrimaryGeneratedColumn({ type: 'int' }) - id!: number; + @Column('text') + content: string; - @Column({ name: 'card_set_id', type: 'int', nullable: false }) - cardSetId!: number; + @Column({ default: 0 }) + order: number; - @ManyToOne(() => Cardset, { createForeignKeyConstraints: false }) - @JoinColumn({ name: 'card_set_id' }) - cardset?: Cardset; + @Column() + cardsetId: number; - @Column({ type: 'text', nullable: false }) - content!: string; -} + @ManyToOne(() => Cardset, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'cardsetId' }) + cardset: Cardset; +} \ No newline at end of file diff --git a/src/cardset/cardset.controller.ts b/src/cardset/cardset.controller.ts index f4fd1c4..dee4418 100644 --- a/src/cardset/cardset.controller.ts +++ b/src/cardset/cardset.controller.ts @@ -1,7 +1,41 @@ -import { Controller } from '@nestjs/common'; +import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common'; import { CardsetService } from './cardset.service'; +import { Cardset } from './entities/cardset.entity'; -@Controller('cardset') +@Controller('cardsets') export class CardsetController { constructor(private readonly cardsetService: CardsetService) {} -} + + @Post() + async create(@Body() cardsetData: Partial): Promise { + return await this.cardsetService.create(cardsetData); + } + + @Get() + async findAll(): Promise { + return await this.cardsetService.findAll(); + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise { + return await this.cardsetService.findOne(parseInt(id)); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() updateData: Partial): Promise { + return await this.cardsetService.update(parseInt(id), updateData); + } + + @Delete(':id') + async remove(@Param('id') id: string): Promise { + return await this.cardsetService.remove(parseInt(id)); + } + + @Put(':id/card-count') + async updateCardCount( + @Param('id') id: string, + @Body() body: { cardCount: number } + ): Promise { + return await this.cardsetService.updateCardCount(parseInt(id), body.cardCount); + } +} \ No newline at end of file diff --git a/src/cardset/cardset.module.ts b/src/cardset/cardset.module.ts index 2153f26..cddce83 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -1,9 +1,14 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Cardset } from './entities/cardset.entity'; import { CardsetService } from './cardset.service'; import { CardsetController } from './cardset.controller'; +import { CardModule } from '../card/card.module'; @Module({ + imports: [TypeOrmModule.forFeature([Cardset]), CardModule], controllers: [CardsetController], providers: [CardsetService], + exports: [CardsetService], }) -export class CardsetModule {} +export class CardsetModule {} \ No newline at end of file diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts index 927df7f..8c8bb16 100644 --- a/src/cardset/cardset.service.ts +++ b/src/cardset/cardset.service.ts @@ -1,4 +1,81 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cardset } from './entities/cardset.entity'; +import { CardService } from '../card/card.service'; @Injectable() -export class CardsetService {} +export class CardsetService { + constructor( + @InjectRepository(Cardset) + private cardsetRepository: Repository, + private cardService: CardService, + ) {} + + async create(cardsetData: Partial): Promise { + const cardset = this.cardsetRepository.create(cardsetData); + const savedCardset = await this.cardsetRepository.save(cardset); + + // 지정된 개수만큼 빈 카드 생성 + const cardCount = cardsetData.cardCount || 10; + for (let i = 0; i < cardCount; i++) { + await this.cardService.create({ + content: '', + order: i, + cardsetId: savedCardset.id, + }); + } + + return savedCardset; + } + + async findAll(): Promise { + return await this.cardsetRepository.find({ + order: { createdAt: 'DESC' }, + }); + } + + async findOne(id: number): Promise { + return await this.cardsetRepository.findOne({ + where: { id }, + }); + } + + async update(id: number, updateData: Partial): Promise { + await this.cardsetRepository.update(id, updateData); + return await this.findOne(id); + } + + async remove(id: number): Promise { + await this.cardsetRepository.delete(id); + } + + async updateCardCount(id: number, newCardCount: number): Promise { + const cardset = await this.findOne(id); + if (!cardset) return null; + + const currentCards = await this.cardService.findAllByCardsetId(id); + const currentCount = currentCards.length; + + if (newCardCount > currentCount) { + // 카드 추가 + for (let i = currentCount; i < newCardCount; i++) { + await this.cardService.create({ + content: '', + order: i, + cardsetId: id, + }); + } + } else if (newCardCount < currentCount) { + // 카드 삭제 (뒤에서부터) + const cardsToDelete = currentCards.slice(newCardCount); + for (const card of cardsToDelete) { + await this.cardService.remove(card.id); + } + } + + // 카드셋의 cardCount 업데이트 + await this.cardsetRepository.update(id, { cardCount: newCardCount }); + return await this.findOne(id); + } +} \ No newline at end of file diff --git a/src/cardset/entities/cardset.entity.ts b/src/cardset/entities/cardset.entity.ts index bc91d83..edc1397 100644 --- a/src/cardset/entities/cardset.entity.ts +++ b/src/cardset/entities/cardset.entity.ts @@ -1,7 +1,8 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { BaseEntity } from '../../common/entities/base.entity'; -@Entity('card_set') -export class Cardset { +@Entity('card_sets') +export class Cardset extends BaseEntity { @PrimaryGeneratedColumn({ type: 'int' }) id!: number; @@ -27,4 +28,7 @@ export class Cardset { @Column({ name: 'image_url', type: 'varchar', nullable: false }) imageUrl!: string; + + @Column({ name: 'card_count', type: 'int', default: 10 }) + cardCount!: number; } diff --git a/src/common/entities/base.entity.ts b/src/common/entities/base.entity.ts new file mode 100644 index 0000000..ff458ff --- /dev/null +++ b/src/common/entities/base.entity.ts @@ -0,0 +1,12 @@ +import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; + +export abstract class BaseEntity { + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt?: Date; +} From 0ebbf007d3638492586e2836471f47968e281c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Thu, 23 Oct 2025 00:51:14 +0900 Subject: [PATCH 02/11] =?UTF-8?q?Refactor:=20=EC=9B=B9=EC=86=8C=EC=BC=93?= =?UTF-8?q?=EC=8B=9C=20=EC=B9=B4=EB=93=9C=EC=85=8B=EA=B3=BC=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/decorators/ws-user.decorator.ts | 9 +- src/types/userAuth.type.ts | 2 +- src/websocket/entities/yjs-document.entity.ts | 22 + src/websocket/websocket.gateway.ts | 400 ++++++------------ src/websocket/websocket.module.ts | 6 +- src/websocket/yjs-document.service.ts | 106 +++++ test-cardset-websocket.html | 306 ++++++++++++++ 7 files changed, 570 insertions(+), 281 deletions(-) create mode 100644 src/websocket/entities/yjs-document.entity.ts create mode 100644 src/websocket/yjs-document.service.ts create mode 100644 test-cardset-websocket.html diff --git a/src/decorators/ws-user.decorator.ts b/src/decorators/ws-user.decorator.ts index 020b9f3..c81d024 100644 --- a/src/decorators/ws-user.decorator.ts +++ b/src/decorators/ws-user.decorator.ts @@ -1,9 +1,8 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { UserAuth } from '../types/userAuth.type'; export const WsUser = createParamDecorator( - (data: unknown, ctx: ExecutionContext): UserAuth => { - const client = ctx.switchToWs().getClient<{ data: { user?: UserAuth } }>(); - return client.data?.user as UserAuth; + (data: unknown, ctx: ExecutionContext) => { + const client = ctx.switchToWs().getClient<{ data: { user?: any } }>(); + return client.data?.user; }, -); +); \ No newline at end of file diff --git a/src/types/userAuth.type.ts b/src/types/userAuth.type.ts index bf82ae7..d4cb0ad 100644 --- a/src/types/userAuth.type.ts +++ b/src/types/userAuth.type.ts @@ -2,4 +2,4 @@ export interface UserAuth { userId: string; role: string; tokenVersion: number; -} +} \ No newline at end of file diff --git a/src/websocket/entities/yjs-document.entity.ts b/src/websocket/entities/yjs-document.entity.ts new file mode 100644 index 0000000..7f6c5d4 --- /dev/null +++ b/src/websocket/entities/yjs-document.entity.ts @@ -0,0 +1,22 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('yjs_documents') +export class YjsDocument { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ name: 'cardset_id', type: 'int', unique: true }) + cardsetId!: number; + + @Column({ name: 'document_data', type: 'blob' }) + documentData!: Buffer; + + @Column({ name: 'version', type: 'int', default: 1 }) + version!: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts index f791b30..62fa4be 100644 --- a/src/websocket/websocket.gateway.ts +++ b/src/websocket/websocket.gateway.ts @@ -2,17 +2,18 @@ import { WebSocketGateway, WebSocketServer, SubscribeMessage, + ConnectedSocket, + MessageBody, OnGatewayConnection, OnGatewayDisconnect, - MessageBody, - ConnectedSocket, } from '@nestjs/websockets'; -import { Server, Socket } from 'socket.io'; import { Logger, UseGuards } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; import * as Y from 'yjs'; import { WsAuthGuard } from '../auth/ws-auth.guard'; import { WsUser } from '../decorators/ws-user.decorator'; -import { UserAuth } from '../types/userAuth.type'; +import type { UserAuth } from '../types/userAuth.type'; + @UseGuards(WsAuthGuard) // 인증 가드 적용 @WebSocketGateway({ cors: { @@ -22,352 +23,205 @@ import { UserAuth } from '../types/userAuth.type'; pingTimeout: 60000, // 60초 pingInterval: 25000, // 25초 }) -export class CollaborationGateway - implements OnGatewayConnection, OnGatewayDisconnect -{ +export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server: Server; private readonly logger = new Logger(CollaborationGateway.name); - private documentMap = new Map(); // documentId -> Y.Doc + private documentMap = new Map(); // cardsetId -> Y.Doc constructor() {} handleConnection(client: Socket) { this.logger.log(`Client connected: ${client.id}`); - // Socket.IO의 내장 PING/PONG 하트비트가 자동으로 처리됩니다 } handleDisconnect(client: Socket) { this.logger.log(`Client disconnected: ${client.id}`); - this.logger.log( - `Disconnect reason: ${client.disconnected ? 'Client initiated' : 'Server initiated'}`, - ); - // Socket.IO의 내장 하트비트가 자동으로 연결 상태를 관리합니다 + // 클라이언트가 연결된 모든 카드셋에서 나가기 + for (const [cardsetId, doc] of this.documentMap) { + void client.leave(`cardset:${cardsetId}`); + } } - @SubscribeMessage('joinRoom') - handleJoinDocument( + // 카드셋에 조인 (카드셋의 Yjs 문서에 접근) + @SubscribeMessage('join-cardset') + async handleJoinCardset( + @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; userId?: string }, + @MessageBody() data: { cardsetId: string }, ) { try { - this.logger.log( - `Client ${client.id} joining document: ${data.documentId}`, - ); + const { cardsetId } = data; + this.logger.log(`User ${user.userId} joining cardset ${cardsetId}`); - // 클라이언트를 룸에 조인 - void client.join(data.documentId); + // 카드셋 룸에 조인 + void client.join(`cardset:${cardsetId}`); - // Yjs 문서 초기화 또는 가져오기 - let doc = this.documentMap.get(data.documentId); + // 카드셋의 Yjs 문서 가져오기 또는 생성 + let doc = this.documentMap.get(cardsetId); if (!doc) { doc = new Y.Doc(); - this.documentMap.set(data.documentId, doc); + this.documentMap.set(cardsetId, doc); + this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); } - // 클라이언트에게 현재 문서 상태 전송 - const state = Y.encodeStateAsUpdate(doc); - client.emit('sync', { - documentId: data.documentId, - update: Array.from(state), - }); - - // 조인 성공 응답 - client.emit('joinRoom', { - documentId: data.documentId, - clientId: client.id, - timestamp: new Date().toISOString(), + // 클라이언트에게 현재 카드셋 상태 전송 + const cards = doc.getArray('cards'); + client.emit('cardset-state', { + cardsetId, + cards: cards.toArray(), }); - // 다른 클라이언트들에게 새 클라이언트 알림 - client.to(data.documentId).emit('user-joined', { - clientId: client.id, - userId: data.userId, - timestamp: new Date().toISOString(), - }); + this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); } catch (error) { - this.logger.error( - `Error joining document: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - client.emit('error', { message: 'Failed to join document' }); + this.logger.error('Error joining cardset:', error); + client.emit('error', { message: 'Failed to join cardset' }); } } - @SubscribeMessage('leaveRoom') - handleLeaveDocument( + // 카드셋에서 나가기 + @SubscribeMessage('leave-cardset') + async handleLeaveCardset( + @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string }, + @MessageBody() data: { cardsetId: string }, ) { try { - this.logger.log( - `Client ${client.id} leaving document: ${data.documentId}`, - ); + const { cardsetId } = data; + this.logger.log(`User ${user.userId} leaving cardset ${cardsetId}`); - // YJS 서비스 제거로 인한 간단한 처리 - - // 룸에서 나가기 - void client.leave(data.documentId); - - // 나가기 성공 응답 - client.emit('userLeft', { - documentId: data.documentId, - clientId: client.id, - timestamp: new Date().toISOString(), - }); - - // 다른 클라이언트들에게 클라이언트 나감 알림 - client.to(data.documentId).emit('userLeft', { - clientId: client.id, - timestamp: new Date().toISOString(), - }); + void client.leave(`cardset:${cardsetId}`); + this.logger.log(`User ${user.userId} left cardset ${cardsetId}`); } catch (error) { - this.logger.error( - `Error leaving document: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - client.emit('error', { message: 'Failed to leave document' }); + this.logger.error('Error leaving cardset:', error); } } - @SubscribeMessage('sendMessage') - handleTextUpdate( - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; field: string; content: string }, - ) { - try { - this.logger.log( - `Received text update from client ${client.id} for document ${data.documentId} (${data.field})`, - ); - - // 텍스트 업데이트를 다른 클라이언트들에게 브로드캐스트 - client.to(data.documentId).emit('text-update', { - documentId: data.documentId, - field: data.field, - content: data.content, - fromClientId: client.id, - timestamp: new Date().toISOString(), - }); - } catch (error) { - this.logger.error( - `Error broadcasting text update: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - @SubscribeMessage('sync') - handleSync( + // 카드 업데이트 + @SubscribeMessage('update-card') + async handleUpdateCard( @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() - data: { documentId: string; syncStep: number; update?: number[] }, + @MessageBody() data: { cardsetId: string; cardId: string; updates: Partial<{ content: string; order: number }> }, ) { try { - this.logger.log( - `Received sync from client ${client.id} (user: ${user.userId}) for document ${data.documentId}`, - ); - - const doc = this.documentMap.get(data.documentId); + const { cardsetId, cardId, updates } = data; + this.logger.log(`User ${user.userId} updating card ${cardId} in cardset ${cardsetId}`); + const doc = this.documentMap.get(cardsetId); if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); + client.emit('error', { message: 'Cardset not found' }); return; } - this.handleSyncMessage(client, doc, data.documentId, data); + const cards = doc.getArray('cards'); + const cardsArray = cards.toArray(); + const cardIndex = cardsArray.findIndex((card: any) => card.id === cardId); + + if (cardIndex === -1) { + client.emit('error', { message: 'Card not found' }); + return; + } + + const currentCard = cardsArray[cardIndex] as any; + const updatedCard = { + ...currentCard, + ...updates, + updatedAt: new Date().toISOString(), + }; + + cards.delete(cardIndex, 1); + cards.insert(cardIndex, [updatedCard]); + + this.logger.log(`Card ${cardId} updated in cardset ${cardsetId}`); } catch (error) { - this.logger.error( - `Error processing sync from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); + this.logger.error('Error updating card:', error); + client.emit('error', { message: 'Failed to update card' }); } } - @SubscribeMessage('update') - handleUpdate( + + // Yjs 동기화 (클라이언트가 변경사항을 받을 때) + @SubscribeMessage('sync') + async handleSync( @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; update: number[] }, + @MessageBody() data: { cardsetId: string; syncStep: number; update?: number[] }, ) { try { - this.logger.log( - `Received update from client ${client.id} (user: ${user.userId}) for document ${data.documentId}`, - ); - - const doc = this.documentMap.get(data.documentId); + const { cardsetId, syncStep, update } = data; + this.logger.log(`Sync request from user ${user.userId} for cardset ${cardsetId}`); + const doc = this.documentMap.get(cardsetId); if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); + client.emit('error', { message: 'Cardset not found' }); return; } - this.handleUpdateMessage(client, doc, data.documentId, data); + if (update) { + // 클라이언트에서 온 업데이트 적용 + Y.applyUpdate(doc, new Uint8Array(update)); + } + + // 현재 상태를 클라이언트에게 전송 + const state = Y.encodeStateAsUpdate(doc); + client.emit('sync-response', { + cardsetId, + update: Array.from(state), + }); } catch (error) { - this.logger.error( - `Error processing update from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); + this.logger.error('Error during sync:', error); + client.emit('error', { message: 'Sync failed' }); } } - @SubscribeMessage('awareness') - handleAwareness( + // 카드 순서 변경 + @SubscribeMessage('reorder-cards') + async handleReorderCards( @WsUser() user: UserAuth, @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; awareness: number[] }, + @MessageBody() data: { cardsetId: string; cardOrders: { cardId: string; order: number }[] }, ) { try { - this.logger.log( - `Received awareness from client ${client.id} (user: ${user.userId}) for document ${data.documentId}`, - ); - - const doc = this.documentMap.get(data.documentId); + const { cardsetId, cardOrders } = data; + this.logger.log(`User ${user.userId} reordering cards in cardset ${cardsetId}`); + const doc = this.documentMap.get(cardsetId); if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); + client.emit('error', { message: 'Cardset not found' }); return; } - this.handleAwarenessMessage(client, doc, data.documentId, data); - } catch (error) { - this.logger.error( - `Error processing awareness from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - - @SubscribeMessage('auth') - handleAuthMessage( - @ConnectedSocket() client: Socket, - @MessageBody() data: { token: string; userId: string; documentId: string }, - ) { - try { - this.logger.log( - `Received auth from client ${client.id}, userId: ${data.userId}, documentId: ${data.documentId}`, - ); - - this.handleAuth(client, data); - } catch (error) { - this.logger.error( - `Error processing auth from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - - private handleSyncMessage( - client: Socket, - doc: Y.Doc, - documentId: string, - data: any, - ) { - const { syncStep, update } = data as { - syncStep: number; - update?: number[]; - }; - - if (syncStep === 0) { - // Step 0: 클라이언트가 현재 상태 벡터 전송 - const stateVector = Y.encodeStateVector(doc); - client.emit('sync', { - syncStep: 1, - update: Array.from(stateVector), + const cards = doc.getArray('cards'); + const cardsArray = cards.toArray(); + + // 순서 업데이트 + cardOrders.forEach(({ cardId, order }) => { + const cardIndex = cardsArray.findIndex((card: any) => card.id === cardId); + if (cardIndex !== -1) { + const currentCard = cardsArray[cardIndex] as any; + cardsArray[cardIndex] = { + ...currentCard, + order, + updatedAt: new Date().toISOString(), + }; + } }); - } else if (syncStep === 1 && update) { - // Step 1: 서버가 차이점 전송 - const diff = Y.encodeStateAsUpdate( - doc, - new Uint8Array(update as unknown as ArrayBufferLike), - ); - client.emit('sync', { - syncStep: 1, - update: Array.from(diff), - }); - } - } - - private handleUpdateMessage( - client: Socket, - doc: Y.Doc, - documentId: string, - data: any, - ) { - const { update } = data as { update: number[] }; - Y.applyUpdate(doc, new Uint8Array(update as unknown as ArrayBufferLike)); - // 다른 클라이언트들에게 sync 메시지로 브로드캐스트 - client.to(documentId).emit('sync', { - syncStep: 1, - update: Array.from(update), - }); - } - - private handleAwarenessMessage( - client: Socket, - doc: Y.Doc, - documentId: string, - data: any, - ) { - const { awareness } = data as { awareness: number[] }; - - // 다른 클라이언트들에게 awareness 브로드캐스트 - client.to(documentId).emit('awareness', { - awareness: Array.from(awareness), - }); - } + // 정렬된 순서로 다시 설정 + cardsArray.sort((a: any, b: any) => a.order - b.order); + + // 전체 배열 교체 + cards.delete(0, cards.length); + cards.insert(0, cardsArray); - private handleAuth( - client: Socket, - data: { token: string; userId: string; documentId: string }, - ) { - try { - this.logger.log( - `Received auth from client ${client.id}, userId: ${data.userId}, documentId: ${data.documentId}`, - ); - this.logger.log(`Auth data: ${JSON.stringify(data)}`); - - // TODO: 실제 JWT 토큰 검증 로직 구현 - // 임시로 토큰이 있으면 인증 성공으로 처리 - const hasAccess = true; - - const accessControlMessage = { - data: { - hasAccess, - message: hasAccess - ? 'Authentication successful' - : 'Authentication failed', - }, - clientId: client.id, - timestamp: new Date().toISOString(), - }; - - this.logger.log( - `Sending access-control to client ${client.id}: ${JSON.stringify(accessControlMessage)}`, - ); - client.emit('access-control', accessControlMessage); - - // 클라이언트가 이벤트를 받을 시간을 주기 위해 약간의 지연 - setTimeout(() => { - this.logger.log(`Auth result for client ${client.id}: ${hasAccess}`); - }, 100); + this.logger.log(`Cards reordered in cardset ${cardsetId}`); } catch (error) { - this.logger.error( - `Auth error: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - this.logger.error(`Auth data: ${JSON.stringify(data)}`); - client.emit('access-control', { - data: { - hasAccess: false, - message: 'Authentication error', - }, - clientId: client.id, - timestamp: new Date().toISOString(), - }); + this.logger.error('Error reordering cards:', error); + client.emit('error', { message: 'Failed to reorder cards' }); } } -} + +} \ No newline at end of file diff --git a/src/websocket/websocket.module.ts b/src/websocket/websocket.module.ts index a6c3327..d36ca66 100644 --- a/src/websocket/websocket.module.ts +++ b/src/websocket/websocket.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { CollaborationGateway } from './websocket.gateway'; +import { AuthModule } from '../auth/auth.module'; @Module({ + imports: [AuthModule], providers: [CollaborationGateway], - exports: [], + exports: [CollaborationGateway], }) -export class WebSocketModule {} +export class WebSocketModule {} \ No newline at end of file diff --git a/src/websocket/yjs-document.service.ts b/src/websocket/yjs-document.service.ts new file mode 100644 index 0000000..e490913 --- /dev/null +++ b/src/websocket/yjs-document.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as Y from 'yjs'; +import { YjsDocument } from './entities/yjs-document.entity'; + +@Injectable() +export class YjsDocumentService { + private readonly logger = new Logger(YjsDocumentService.name); + private documentCache = new Map(); + + constructor( + @InjectRepository(YjsDocument) + private yjsDocumentRepository: Repository, + ) {} + + async getOrCreateDocument(cardsetId: number): Promise { + // 캐시에서 먼저 확인 + if (this.documentCache.has(cardsetId)) { + return this.documentCache.get(cardsetId)!; + } + + // DB에서 문서 로드 시도 + let yjsDoc = await this.yjsDocumentRepository.findOne({ + where: { cardsetId }, + }); + + let doc: Y.Doc; + + if (yjsDoc) { + // DB에서 문서 복원 + doc = new Y.Doc(); + Y.applyUpdate(doc, yjsDoc.documentData); + this.logger.log(`Loaded Yjs document for cardset ${cardsetId} from DB`); + } else { + // 새 문서 생성 + doc = new Y.Doc(); + await this.saveDocument(cardsetId, doc); + this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); + } + + // 변경사항 감지하여 DB에 자동 저장 + doc.on('update', async (update: Uint8Array) => { + await this.saveDocument(cardsetId, doc); + }); + + // 캐시에 저장 + this.documentCache.set(cardsetId, doc); + return doc; + } + + async saveDocument(cardsetId: number, doc: Y.Doc): Promise { + try { + const state = Y.encodeStateAsUpdate(doc); + + const existingDoc = await this.yjsDocumentRepository.findOne({ + where: { cardsetId }, + }); + + if (existingDoc) { + await this.yjsDocumentRepository.update(existingDoc.id, { + documentData: Buffer.from(state), + version: existingDoc.version + 1, + }); + } else { + const yjsDoc = this.yjsDocumentRepository.create({ + cardsetId, + documentData: Buffer.from(state), + version: 1, + }); + await this.yjsDocumentRepository.save(yjsDoc); + } + } catch (error) { + this.logger.error(`Failed to save Yjs document for cardset ${cardsetId}:`, error); + } + } + + async deleteDocument(cardsetId: number): Promise { + await this.yjsDocumentRepository.delete({ cardsetId }); + this.documentCache.delete(cardsetId); + this.logger.log(`Deleted Yjs document for cardset ${cardsetId}`); + } + + // DB에서 카드 데이터를 Yjs 문서로 동기화 + async syncCardsFromDB(cardsetId: number, cards: any[]): Promise { + const doc = await this.getOrCreateDocument(cardsetId); + const cardsArray = doc.getArray('cards'); + + // 기존 카드 모두 삭제 + cardsArray.delete(0, cardsArray.length); + + // DB 카드들을 Yjs에 추가 + const yjsCards = cards.map(card => ({ + id: card.id, + title: card.title, + content: card.content, + order: card.order, + cardsetId: card.cardsetId, + createdAt: card.createdAt?.toISOString(), + updatedAt: card.updatedAt?.toISOString(), + })); + + cardsArray.insert(0, yjsCards); + this.logger.log(`Synced ${cards.length} cards from DB to Yjs for cardset ${cardsetId}`); + } +} diff --git a/test-cardset-websocket.html b/test-cardset-websocket.html new file mode 100644 index 0000000..0a46008 --- /dev/null +++ b/test-cardset-websocket.html @@ -0,0 +1,306 @@ + + + + + + 카드셋 실시간 협업 테스트 + + + + +

카드셋 실시간 협업 테스트

+ +
+ + + +
+ +
연결되지 않음
+ +
+
+

카드셋 목록

+
+ +

카드 개수 변경

+ + +
+ +
+

카드 목록

+
+
+
+ +
+ + + + From c58b8253f5611d3597b0d0ca9456adf5e36ae4fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 6 Mar 2026 00:11:49 +0900 Subject: [PATCH 03/11] =?UTF-8?q?Refactor:=20ddd=EB=A1=9C=20=ED=98=95?= =?UTF-8?q?=ED=83=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 26 +- src/auth.guard.ts | 32 --- src/auth/auth.controller.ts | 13 - src/auth/auth.module.ts | 8 +- src/auth/{ => domain}/auth.service.ts | 10 +- .../guard}/ws-auth.guard.ts | 5 +- src/card/card.controller.spec.ts | 20 -- src/card/card.controller.ts | 38 --- src/card/card.module.ts | 13 - src/card/card.service.spec.ts | 18 -- src/card/card.service.ts | 43 ---- src/card/entities/card.entity.ts | 28 --- .../cardset-manager.controller.spec.ts | 20 -- .../cardset-manager.controller.ts | 7 - src/cardset-manager/cardset-manager.module.ts | 9 - .../cardset-manager.service.spec.ts | 18 -- .../cardset-manager.service.ts | 4 - src/cardset/application/card.use-case.ts | 41 ++++ src/cardset/application/cardset.use-case.ts | 83 +++++++ .../application/dto/create-card.dto.ts | 5 + .../application/dto/create-cardset.dto.ts | 9 + .../application/dto/reorder-cards.dto.ts | 3 + .../application/dto/update-card.dto.ts | 4 + .../application/dto/update-cardset.dto.ts | 7 + src/cardset/cardset.controller.spec.ts | 20 -- src/cardset/cardset.controller.ts | 41 ---- src/cardset/cardset.module.ts | 44 +++- src/cardset/cardset.service.spec.ts | 18 -- src/cardset/cardset.service.ts | 81 ------- src/cardset/domain/model/card.ts | 50 ++++ src/cardset/domain/model/cardset-manager.ts | 11 + src/cardset/domain/model/cardset.ts | 76 ++++++ .../domain/repository/card.repository.ts | 12 + .../domain/repository/cardset.repository.ts | 11 + .../service/cardset-card.domain-service.ts | 21 ++ .../infrastructure/http/card.controller.ts | 68 ++++++ .../infrastructure/http/cardset.controller.ts | 66 +++++ .../persistence/card.repository.impl.ts | 48 ++++ .../persistence/cardset.repository.impl.ts | 43 ++++ .../persistence/mapper/card.mapper.ts | 25 ++ .../persistence/mapper/cardset.mapper.ts | 33 +++ .../persistence/orm/card.orm-entity.ts | 28 +++ .../orm/cardset-manager.orm-entity.ts} | 2 +- .../persistence/orm/cardset.orm-entity.ts} | 4 +- .../application/collaboration.use-case.ts | 98 ++++++++ src/collaboration/collaboration.module.ts | 19 ++ .../domain/model/yjs-document.ts | 32 +++ .../repository/yjs-document.repository.ts | 10 + .../gateway/collaboration.gateway.ts | 158 ++++++++++++ .../persistence/mapper/yjs-document.mapper.ts | 24 ++ .../orm/yjs-document.orm-entity.ts} | 10 +- .../yjs-document.repository.impl.ts | 38 +++ src/decorators/auth-user.decorator.ts | 10 - src/decorators/ws-user.decorator.ts | 8 - .../config/auth.config.ts} | 2 +- src/shared/decorator/ws-user.decorator.ts | 10 + .../persistence/base.orm-entity.ts} | 2 +- .../types/user-auth.type.ts} | 2 +- src/types/express.d.ts | 8 - src/websocket/websocket.gateway.ts | 227 ------------------ src/websocket/websocket.module.ts | 10 - src/websocket/yjs-document.service.ts | 106 -------- 62 files changed, 1106 insertions(+), 834 deletions(-) delete mode 100644 src/auth.guard.ts delete mode 100644 src/auth/auth.controller.ts rename src/auth/{ => domain}/auth.service.ts (74%) rename src/auth/{ => infrastructure/guard}/ws-auth.guard.ts (83%) delete mode 100644 src/card/card.controller.spec.ts delete mode 100644 src/card/card.controller.ts delete mode 100644 src/card/card.module.ts delete mode 100644 src/card/card.service.spec.ts delete mode 100644 src/card/card.service.ts delete mode 100644 src/card/entities/card.entity.ts delete mode 100644 src/cardset-manager/cardset-manager.controller.spec.ts delete mode 100644 src/cardset-manager/cardset-manager.controller.ts delete mode 100644 src/cardset-manager/cardset-manager.module.ts delete mode 100644 src/cardset-manager/cardset-manager.service.spec.ts delete mode 100644 src/cardset-manager/cardset-manager.service.ts create mode 100644 src/cardset/application/card.use-case.ts create mode 100644 src/cardset/application/cardset.use-case.ts create mode 100644 src/cardset/application/dto/create-card.dto.ts create mode 100644 src/cardset/application/dto/create-cardset.dto.ts create mode 100644 src/cardset/application/dto/reorder-cards.dto.ts create mode 100644 src/cardset/application/dto/update-card.dto.ts create mode 100644 src/cardset/application/dto/update-cardset.dto.ts delete mode 100644 src/cardset/cardset.controller.spec.ts delete mode 100644 src/cardset/cardset.controller.ts delete mode 100644 src/cardset/cardset.service.spec.ts delete mode 100644 src/cardset/cardset.service.ts create mode 100644 src/cardset/domain/model/card.ts create mode 100644 src/cardset/domain/model/cardset-manager.ts create mode 100644 src/cardset/domain/model/cardset.ts create mode 100644 src/cardset/domain/repository/card.repository.ts create mode 100644 src/cardset/domain/repository/cardset.repository.ts create mode 100644 src/cardset/domain/service/cardset-card.domain-service.ts create mode 100644 src/cardset/infrastructure/http/card.controller.ts create mode 100644 src/cardset/infrastructure/http/cardset.controller.ts create mode 100644 src/cardset/infrastructure/persistence/card.repository.impl.ts create mode 100644 src/cardset/infrastructure/persistence/cardset.repository.impl.ts create mode 100644 src/cardset/infrastructure/persistence/mapper/card.mapper.ts create mode 100644 src/cardset/infrastructure/persistence/mapper/cardset.mapper.ts create mode 100644 src/cardset/infrastructure/persistence/orm/card.orm-entity.ts rename src/{cardset-manager/entities/cardset-manager.entity.ts => cardset/infrastructure/persistence/orm/cardset-manager.orm-entity.ts} (92%) rename src/cardset/{entities/cardset.entity.ts => infrastructure/persistence/orm/cardset.orm-entity.ts} (83%) create mode 100644 src/collaboration/application/collaboration.use-case.ts create mode 100644 src/collaboration/collaboration.module.ts create mode 100644 src/collaboration/domain/model/yjs-document.ts create mode 100644 src/collaboration/domain/repository/yjs-document.repository.ts create mode 100644 src/collaboration/infrastructure/gateway/collaboration.gateway.ts create mode 100644 src/collaboration/infrastructure/persistence/mapper/yjs-document.mapper.ts rename src/{websocket/entities/yjs-document.entity.ts => collaboration/infrastructure/persistence/orm/yjs-document.orm-entity.ts} (74%) create mode 100644 src/collaboration/infrastructure/persistence/yjs-document.repository.impl.ts delete mode 100644 src/decorators/auth-user.decorator.ts delete mode 100644 src/decorators/ws-user.decorator.ts rename src/{config/authConfig.ts => shared/config/auth.config.ts} (62%) create mode 100644 src/shared/decorator/ws-user.decorator.ts rename src/{common/entities/base.entity.ts => shared/infrastructure/persistence/base.orm-entity.ts} (87%) rename src/{types/userAuth.type.ts => shared/types/user-auth.type.ts} (97%) delete mode 100644 src/types/express.d.ts delete mode 100644 src/websocket/websocket.gateway.ts delete mode 100644 src/websocket/websocket.module.ts delete mode 100644 src/websocket/yjs-document.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index fd55b4e..ebf6f67 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,14 +3,15 @@ import { ConfigModule } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from './auth/auth.module'; import { CardsetModule } from './cardset/cardset.module'; -import { CardModule } from './card/card.module'; -import { Cardset } from './cardset/entities/cardset.entity'; -import { Card } from './card/entities/card.entity'; -import { WebSocketModule } from './websocket/websocket.module'; +import { CollaborationModule } from './collaboration/collaboration.module'; + +import { CardsetOrmEntity } from './cardset/infrastructure/persistence/orm/cardset.orm-entity'; +import { CardOrmEntity } from './cardset/infrastructure/persistence/orm/card.orm-entity'; +import { CardsetManagerOrmEntity } from './cardset/infrastructure/persistence/orm/cardset-manager.orm-entity'; +import { YjsDocumentOrmEntity } from './collaboration/infrastructure/persistence/orm/yjs-document.orm-entity'; @Module({ imports: [ - AuthModule, ConfigModule.forRoot({ isGlobal: true }), TypeOrmModule.forRoot({ type: 'mysql', @@ -19,14 +20,17 @@ import { WebSocketModule } from './websocket/websocket.module'; username: process.env.DB_USERNAME, password: process.env.DB_PASSWORD, database: process.env.DB_DATABASE, - entities: [Cardset, Card], - synchronize: true, // 테이블 자동 생성 + entities: [ + CardsetOrmEntity, + CardOrmEntity, + CardsetManagerOrmEntity, + YjsDocumentOrmEntity, + ], + synchronize: true, }), + AuthModule, CardsetModule, - CardModule, - WebSocketModule, + CollaborationModule, ], - controllers: [], - providers: [], }) export class AppModule {} diff --git a/src/auth.guard.ts b/src/auth.guard.ts deleted file mode 100644 index e1b50a2..0000000 --- a/src/auth.guard.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Request } from 'express'; -import { Observable } from 'rxjs'; -import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { AuthService } from './auth/auth.service'; - -@Injectable() -export class AuthGuard implements CanActivate { - constructor(private authService: AuthService) {} - - canActivate( - context: ExecutionContext, - ): boolean | Promise | Observable { - const request = context.switchToHttp().getRequest(); - return this.validateRequest(request); - } - - private validateRequest(request: Request) { - const authHeader = request.headers.authorization; - - if (authHeader) { - const token = authHeader.startsWith('Bearer ') - ? authHeader.slice(7) - : null; - if (token) { - request.user = this.authService.verify(token); - return true; - } - } - - return false; - } -} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts deleted file mode 100644 index 09e69fc..0000000 --- a/src/auth/auth.controller.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; -import type { UserAuth } from '../types/userAuth.type'; -import { AuthUser } from '../decorators/auth-user.decorator'; -import { AuthGuard } from '../auth.guard'; - -@Controller('auth') -export class AuthController { - @UseGuards(AuthGuard) - @Get('test') - test(@AuthUser() userAuth: UserAuth): UserAuth { - return userAuth; - } -} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 7c6df0f..db9b10d 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -1,13 +1,11 @@ import { Module } from '@nestjs/common'; -import { AuthService } from './auth.service'; -import { AuthController } from './auth.controller'; -import { WsAuthGuard } from './ws-auth.guard'; -import authConfig from '../config/authConfig'; import { ConfigModule } from '@nestjs/config'; +import { AuthService } from './domain/auth.service'; +import { WsAuthGuard } from './infrastructure/guard/ws-auth.guard'; +import authConfig from '../shared/config/auth.config'; @Module({ imports: [ConfigModule.forFeature(authConfig)], - controllers: [AuthController], providers: [AuthService, WsAuthGuard], exports: [AuthService, WsAuthGuard], }) diff --git a/src/auth/auth.service.ts b/src/auth/domain/auth.service.ts similarity index 74% rename from src/auth/auth.service.ts rename to src/auth/domain/auth.service.ts index 736e443..6adca29 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/domain/auth.service.ts @@ -1,10 +1,10 @@ import * as jwt from 'jsonwebtoken'; import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; -import authConfig from 'src/config/authConfig'; +import authConfig from '../../shared/config/auth.config'; import type { ConfigType } from '@nestjs/config'; -import { UserAuth } from '../types/userAuth.type'; +import type { UserAuth } from '../../shared/types/user-auth.type'; -interface User { +interface JwtUser { user_id: string; role: string; token_version: number; @@ -18,11 +18,11 @@ export class AuthService { verify(token: string): UserAuth { try { - const payload = jwt.verify(token, this.config.jwtSecret) as unknown as ( + const payload = jwt.verify(token, this.config.jwtSecret!) as unknown as ( | jwt.JwtPayload | string ) & - User; + JwtUser; const { user_id, role, token_version } = payload; diff --git a/src/auth/ws-auth.guard.ts b/src/auth/infrastructure/guard/ws-auth.guard.ts similarity index 83% rename from src/auth/ws-auth.guard.ts rename to src/auth/infrastructure/guard/ws-auth.guard.ts index 127d591..656a2f3 100644 --- a/src/auth/ws-auth.guard.ts +++ b/src/auth/infrastructure/guard/ws-auth.guard.ts @@ -4,7 +4,7 @@ import { Injectable, Logger, } from '@nestjs/common'; -import { AuthService } from './auth.service'; +import { AuthService } from '../../domain/auth.service'; import { Socket } from 'socket.io'; @Injectable() @@ -16,8 +16,6 @@ export class WsAuthGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const client: Socket = context.switchToWs().getClient(); - // 1) socket.io v4: client.handshake.auth.token 로 받기 - // 2) 또는 Authorization 헤더로 받기 const bearer = (client.handshake.auth?.token as string | undefined) ?? client.handshake.headers?.authorization; @@ -32,7 +30,6 @@ export class WsAuthGuard implements CanActivate { try { const user = this.authService.verify(token); - // 이후 핸들러에서 사용할 수 있도록 저장 (client.data as { user: unknown }).user = user; return true; } catch (error) { diff --git a/src/card/card.controller.spec.ts b/src/card/card.controller.spec.ts deleted file mode 100644 index bf807c6..0000000 --- a/src/card/card.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CardController } from './card.controller'; -import { CardService } from './card.service'; - -describe('CardController', () => { - let controller: CardController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [CardController], - providers: [CardService], - }).compile(); - - controller = module.get(CardController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/card/card.controller.ts b/src/card/card.controller.ts deleted file mode 100644 index 2ad3bf8..0000000 --- a/src/card/card.controller.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common'; -import { CardService } from './card.service'; -import { Card } from './entities/card.entity'; - -@Controller('cards') -export class CardController { - constructor(private readonly cardService: CardService) {} - - @Post() - async create(@Body() cardData: Partial): Promise { - return await this.cardService.create(cardData); - } - - @Get('cardset/:cardsetId') - async findByCardsetId(@Param('cardsetId') cardsetId: string): Promise { - return await this.cardService.findAllByCardsetId(parseInt(cardsetId)); - } - - @Get(':id') - async findOne(@Param('id') id: string): Promise { - return await this.cardService.findOne(parseInt(id)); - } - - @Put(':id') - async update(@Param('id') id: string, @Body() updateData: Partial): Promise { - return await this.cardService.update(parseInt(id), updateData); - } - - @Delete(':id') - async remove(@Param('id') id: string): Promise { - return await this.cardService.remove(parseInt(id)); - } - - @Put('reorder') - async reorderCards(@Body() cardOrders: { cardId: number; order: number }[]): Promise { - return await this.cardService.reorderCards(cardOrders); - } -} \ No newline at end of file diff --git a/src/card/card.module.ts b/src/card/card.module.ts deleted file mode 100644 index 291f582..0000000 --- a/src/card/card.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Card } from './entities/card.entity'; -import { CardService } from './card.service'; -import { CardController } from './card.controller'; - -@Module({ - imports: [TypeOrmModule.forFeature([Card])], - controllers: [CardController], - providers: [CardService], - exports: [CardService], -}) -export class CardModule {} \ No newline at end of file diff --git a/src/card/card.service.spec.ts b/src/card/card.service.spec.ts deleted file mode 100644 index 782eb09..0000000 --- a/src/card/card.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CardService } from './card.service'; - -describe('CardService', () => { - let service: CardService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CardService], - }).compile(); - - service = module.get(CardService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/card/card.service.ts b/src/card/card.service.ts deleted file mode 100644 index 6ac9bfe..0000000 --- a/src/card/card.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Card } from './entities/card.entity'; - -@Injectable() -export class CardService { - constructor( - @InjectRepository(Card) - private cardRepository: Repository, - ) {} - - async create(cardData: Partial): Promise { - const card = this.cardRepository.create(cardData); - return await this.cardRepository.save(card); - } - - async findAllByCardsetId(cardsetId: number): Promise { - return await this.cardRepository.find({ - where: { cardsetId }, - order: { order: 'ASC' }, - }); - } - - async findOne(id: number): Promise { - return await this.cardRepository.findOne({ where: { id } }); - } - - async update(id: number, updateData: Partial): Promise { - await this.cardRepository.update(id, updateData); - return await this.findOne(id); - } - - async remove(id: number): Promise { - await this.cardRepository.delete(id); - } - - async reorderCards(cardOrders: { cardId: number; order: number }[]): Promise { - for (const { cardId, order } of cardOrders) { - await this.cardRepository.update(cardId, { order }); - } - } -} \ No newline at end of file diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts deleted file mode 100644 index 3acf3f7..0000000 --- a/src/card/entities/card.entity.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - ManyToOne, - JoinColumn, -} from 'typeorm'; -import { Cardset } from '../../cardset/entities/cardset.entity'; -import { BaseEntity } from '../../common/entities/base.entity'; - -@Entity('cards') -export class Card extends BaseEntity { - @PrimaryGeneratedColumn() - id: number; - - @Column('text') - content: string; - - @Column({ default: 0 }) - order: number; - - @Column() - cardsetId: number; - - @ManyToOne(() => Cardset, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'cardsetId' }) - cardset: Cardset; -} \ No newline at end of file diff --git a/src/cardset-manager/cardset-manager.controller.spec.ts b/src/cardset-manager/cardset-manager.controller.spec.ts deleted file mode 100644 index 7b53c56..0000000 --- a/src/cardset-manager/cardset-manager.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CardsetManagerController } from './cardset-manager.controller'; -import { CardsetManagerService } from './cardset-manager.service'; - -describe('CardsetManagerController', () => { - let controller: CardsetManagerController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [CardsetManagerController], - providers: [CardsetManagerService], - }).compile(); - - controller = module.get(CardsetManagerController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/cardset-manager/cardset-manager.controller.ts b/src/cardset-manager/cardset-manager.controller.ts deleted file mode 100644 index 3d0652a..0000000 --- a/src/cardset-manager/cardset-manager.controller.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Controller } from '@nestjs/common'; -import { CardsetManagerService } from './cardset-manager.service'; - -@Controller('cardset-manager') -export class CardsetManagerController { - constructor(private readonly cardsetManagerService: CardsetManagerService) {} -} diff --git a/src/cardset-manager/cardset-manager.module.ts b/src/cardset-manager/cardset-manager.module.ts deleted file mode 100644 index f307ed2..0000000 --- a/src/cardset-manager/cardset-manager.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CardsetManagerService } from './cardset-manager.service'; -import { CardsetManagerController } from './cardset-manager.controller'; - -@Module({ - controllers: [CardsetManagerController], - providers: [CardsetManagerService], -}) -export class CardsetManagerModule {} diff --git a/src/cardset-manager/cardset-manager.service.spec.ts b/src/cardset-manager/cardset-manager.service.spec.ts deleted file mode 100644 index 0beef93..0000000 --- a/src/cardset-manager/cardset-manager.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CardsetManagerService } from './cardset-manager.service'; - -describe('CardsetManagerService', () => { - let service: CardsetManagerService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CardsetManagerService], - }).compile(); - - service = module.get(CardsetManagerService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/cardset-manager/cardset-manager.service.ts b/src/cardset-manager/cardset-manager.service.ts deleted file mode 100644 index 509abd7..0000000 --- a/src/cardset-manager/cardset-manager.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class CardsetManagerService {} diff --git a/src/cardset/application/card.use-case.ts b/src/cardset/application/card.use-case.ts new file mode 100644 index 0000000..34dd78e --- /dev/null +++ b/src/cardset/application/card.use-case.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Card } from '../domain/model/card'; +import { CARD_REPOSITORY } from '../domain/repository/card.repository'; +import type { ICardRepository } from '../domain/repository/card.repository'; +import { CreateCardDto } from './dto/create-card.dto'; +import { UpdateCardDto } from './dto/update-card.dto'; + +@Injectable() +export class CardUseCase { + constructor( + @Inject(CARD_REPOSITORY) + private readonly cardRepository: ICardRepository, + ) {} + + async create(dto: CreateCardDto): Promise { + const card = Card.create(dto); + return this.cardRepository.save(card); + } + + async findAllByCardsetId(cardsetId: number): Promise { + return this.cardRepository.findAllByCardsetId(cardsetId); + } + + async findOne(id: number): Promise { + return this.cardRepository.findById(id); + } + + async update(id: number, dto: UpdateCardDto): Promise { + return this.cardRepository.update(id, dto); + } + + async remove(id: number): Promise { + return this.cardRepository.delete(id); + } + + async reorderCards(cardOrders: { cardId: number; order: number }[]): Promise { + for (const { cardId, order } of cardOrders) { + await this.cardRepository.updateOrder(cardId, order); + } + } +} diff --git a/src/cardset/application/cardset.use-case.ts b/src/cardset/application/cardset.use-case.ts new file mode 100644 index 0000000..8fed80c --- /dev/null +++ b/src/cardset/application/cardset.use-case.ts @@ -0,0 +1,83 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Cardset } from '../domain/model/cardset'; +import { CARDSET_REPOSITORY } from '../domain/repository/cardset.repository'; +import type { ICardsetRepository } from '../domain/repository/cardset.repository'; +import { CARD_REPOSITORY } from '../domain/repository/card.repository'; +import type { ICardRepository } from '../domain/repository/card.repository'; +import { CardsetCardDomainService } from '../domain/service/cardset-card.domain-service'; +import { CreateCardsetDto } from './dto/create-cardset.dto'; +import { UpdateCardsetDto } from './dto/update-cardset.dto'; + +@Injectable() +export class CardsetUseCase { + constructor( + @Inject(CARDSET_REPOSITORY) + private readonly cardsetRepository: ICardsetRepository, + @Inject(CARD_REPOSITORY) + private readonly cardRepository: ICardRepository, + private readonly cardsetCardDomainService: CardsetCardDomainService, + ) {} + + async create(dto: CreateCardsetDto): Promise { + const cardset = Cardset.create(dto); + const savedCardset = await this.cardsetRepository.save(cardset); + + const cardCount = dto.cardCount ?? 10; + const cardsToAdd = this.cardsetCardDomainService.buildCardsToAdd( + savedCardset.id, + 0, + cardCount, + ); + for (const card of cardsToAdd) { + await this.cardRepository.save(card); + } + + return savedCardset; + } + + async findAll(): Promise { + return this.cardsetRepository.findAll(); + } + + async findOne(id: number): Promise { + return this.cardsetRepository.findById(id); + } + + async update(id: number, dto: UpdateCardsetDto): Promise { + return this.cardsetRepository.update(id, dto); + } + + async remove(id: number): Promise { + return this.cardsetRepository.delete(id); + } + + async updateCardCount(id: number, newCardCount: number): Promise { + const cardset = await this.cardsetRepository.findById(id); + if (!cardset) return null; + + const currentCards = await this.cardRepository.findAllByCardsetId(id); + const currentCount = currentCards.length; + + if (newCardCount > currentCount) { + const cardsToAdd = this.cardsetCardDomainService.buildCardsToAdd( + id, + currentCount, + newCardCount, + ); + for (const card of cardsToAdd) { + await this.cardRepository.save(card); + } + } else if (newCardCount < currentCount) { + const cardsToRemove = this.cardsetCardDomainService.selectCardsToRemove( + currentCards, + newCardCount, + ); + for (const card of cardsToRemove) { + await this.cardRepository.delete(card.id); + } + } + + const updatedCardset = cardset.changeCardCount(newCardCount); + return this.cardsetRepository.update(id, updatedCardset); + } +} diff --git a/src/cardset/application/dto/create-card.dto.ts b/src/cardset/application/dto/create-card.dto.ts new file mode 100644 index 0000000..cef343c --- /dev/null +++ b/src/cardset/application/dto/create-card.dto.ts @@ -0,0 +1,5 @@ +export class CreateCardDto { + content?: string; + order!: number; + cardsetId!: number; +} diff --git a/src/cardset/application/dto/create-cardset.dto.ts b/src/cardset/application/dto/create-cardset.dto.ts new file mode 100644 index 0000000..a521d9b --- /dev/null +++ b/src/cardset/application/dto/create-cardset.dto.ts @@ -0,0 +1,9 @@ +export class CreateCardsetDto { + name!: string; + groupId!: number; + publicVisible!: boolean; + category!: string; + hashtag?: string | null; + imageUrl!: string; + cardCount?: number; +} diff --git a/src/cardset/application/dto/reorder-cards.dto.ts b/src/cardset/application/dto/reorder-cards.dto.ts new file mode 100644 index 0000000..f639bea --- /dev/null +++ b/src/cardset/application/dto/reorder-cards.dto.ts @@ -0,0 +1,3 @@ +export class ReorderCardsDto { + cardOrders!: { cardId: number; order: number }[]; +} diff --git a/src/cardset/application/dto/update-card.dto.ts b/src/cardset/application/dto/update-card.dto.ts new file mode 100644 index 0000000..ec086d1 --- /dev/null +++ b/src/cardset/application/dto/update-card.dto.ts @@ -0,0 +1,4 @@ +export class UpdateCardDto { + content?: string; + order?: number; +} diff --git a/src/cardset/application/dto/update-cardset.dto.ts b/src/cardset/application/dto/update-cardset.dto.ts new file mode 100644 index 0000000..0c1343b --- /dev/null +++ b/src/cardset/application/dto/update-cardset.dto.ts @@ -0,0 +1,7 @@ +export class UpdateCardsetDto { + name?: string; + publicVisible?: boolean; + category?: string; + hashtag?: string | null; + imageUrl?: string; +} diff --git a/src/cardset/cardset.controller.spec.ts b/src/cardset/cardset.controller.spec.ts deleted file mode 100644 index 74b9be8..0000000 --- a/src/cardset/cardset.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CardsetController } from './cardset.controller'; -import { CardsetService } from './cardset.service'; - -describe('CardsetController', () => { - let controller: CardsetController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [CardsetController], - providers: [CardsetService], - }).compile(); - - controller = module.get(CardsetController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/cardset/cardset.controller.ts b/src/cardset/cardset.controller.ts deleted file mode 100644 index dee4418..0000000 --- a/src/cardset/cardset.controller.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common'; -import { CardsetService } from './cardset.service'; -import { Cardset } from './entities/cardset.entity'; - -@Controller('cardsets') -export class CardsetController { - constructor(private readonly cardsetService: CardsetService) {} - - @Post() - async create(@Body() cardsetData: Partial): Promise { - return await this.cardsetService.create(cardsetData); - } - - @Get() - async findAll(): Promise { - return await this.cardsetService.findAll(); - } - - @Get(':id') - async findOne(@Param('id') id: string): Promise { - return await this.cardsetService.findOne(parseInt(id)); - } - - @Put(':id') - async update(@Param('id') id: string, @Body() updateData: Partial): Promise { - return await this.cardsetService.update(parseInt(id), updateData); - } - - @Delete(':id') - async remove(@Param('id') id: string): Promise { - return await this.cardsetService.remove(parseInt(id)); - } - - @Put(':id/card-count') - async updateCardCount( - @Param('id') id: string, - @Body() body: { cardCount: number } - ): Promise { - return await this.cardsetService.updateCardCount(parseInt(id), body.cardCount); - } -} \ No newline at end of file diff --git a/src/cardset/cardset.module.ts b/src/cardset/cardset.module.ts index cddce83..0b53eaa 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -1,14 +1,40 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Cardset } from './entities/cardset.entity'; -import { CardsetService } from './cardset.service'; -import { CardsetController } from './cardset.controller'; -import { CardModule } from '../card/card.module'; + +import { CardsetOrmEntity } from './infrastructure/persistence/orm/cardset.orm-entity'; +import { CardOrmEntity } from './infrastructure/persistence/orm/card.orm-entity'; +import { CardsetManagerOrmEntity } from './infrastructure/persistence/orm/cardset-manager.orm-entity'; + +import { CardsetRepositoryImpl } from './infrastructure/persistence/cardset.repository.impl'; +import { CardRepositoryImpl } from './infrastructure/persistence/card.repository.impl'; + +import { CARDSET_REPOSITORY } from './domain/repository/cardset.repository'; +import { CARD_REPOSITORY } from './domain/repository/card.repository'; + +import { CardsetCardDomainService } from './domain/service/cardset-card.domain-service'; + +import { CardsetUseCase } from './application/cardset.use-case'; +import { CardUseCase } from './application/card.use-case'; + +import { CardsetController } from './infrastructure/http/cardset.controller'; +import { CardController } from './infrastructure/http/card.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Cardset]), CardModule], - controllers: [CardsetController], - providers: [CardsetService], - exports: [CardsetService], + imports: [ + TypeOrmModule.forFeature([ + CardsetOrmEntity, + CardOrmEntity, + CardsetManagerOrmEntity, + ]), + ], + controllers: [CardsetController, CardController], + providers: [ + { provide: CARDSET_REPOSITORY, useClass: CardsetRepositoryImpl }, + { provide: CARD_REPOSITORY, useClass: CardRepositoryImpl }, + CardsetCardDomainService, + CardsetUseCase, + CardUseCase, + ], + exports: [CardsetUseCase, CardUseCase], }) -export class CardsetModule {} \ No newline at end of file +export class CardsetModule {} diff --git a/src/cardset/cardset.service.spec.ts b/src/cardset/cardset.service.spec.ts deleted file mode 100644 index 993a7fc..0000000 --- a/src/cardset/cardset.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CardsetService } from './cardset.service'; - -describe('CardsetService', () => { - let service: CardsetService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CardsetService], - }).compile(); - - service = module.get(CardsetService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/cardset/cardset.service.ts b/src/cardset/cardset.service.ts deleted file mode 100644 index 8c8bb16..0000000 --- a/src/cardset/cardset.service.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Cardset } from './entities/cardset.entity'; -import { CardService } from '../card/card.service'; - -@Injectable() -export class CardsetService { - constructor( - @InjectRepository(Cardset) - private cardsetRepository: Repository, - private cardService: CardService, - ) {} - - async create(cardsetData: Partial): Promise { - const cardset = this.cardsetRepository.create(cardsetData); - const savedCardset = await this.cardsetRepository.save(cardset); - - // 지정된 개수만큼 빈 카드 생성 - const cardCount = cardsetData.cardCount || 10; - for (let i = 0; i < cardCount; i++) { - await this.cardService.create({ - content: '', - order: i, - cardsetId: savedCardset.id, - }); - } - - return savedCardset; - } - - async findAll(): Promise { - return await this.cardsetRepository.find({ - order: { createdAt: 'DESC' }, - }); - } - - async findOne(id: number): Promise { - return await this.cardsetRepository.findOne({ - where: { id }, - }); - } - - async update(id: number, updateData: Partial): Promise { - await this.cardsetRepository.update(id, updateData); - return await this.findOne(id); - } - - async remove(id: number): Promise { - await this.cardsetRepository.delete(id); - } - - async updateCardCount(id: number, newCardCount: number): Promise { - const cardset = await this.findOne(id); - if (!cardset) return null; - - const currentCards = await this.cardService.findAllByCardsetId(id); - const currentCount = currentCards.length; - - if (newCardCount > currentCount) { - // 카드 추가 - for (let i = currentCount; i < newCardCount; i++) { - await this.cardService.create({ - content: '', - order: i, - cardsetId: id, - }); - } - } else if (newCardCount < currentCount) { - // 카드 삭제 (뒤에서부터) - const cardsToDelete = currentCards.slice(newCardCount); - for (const card of cardsToDelete) { - await this.cardService.remove(card.id); - } - } - - // 카드셋의 cardCount 업데이트 - await this.cardsetRepository.update(id, { cardCount: newCardCount }); - return await this.findOne(id); - } -} \ No newline at end of file diff --git a/src/cardset/domain/model/card.ts b/src/cardset/domain/model/card.ts new file mode 100644 index 0000000..a220f07 --- /dev/null +++ b/src/cardset/domain/model/card.ts @@ -0,0 +1,50 @@ +export class Card { + constructor( + public readonly id: number, + public content: string, + public order: number, + public readonly cardsetId: number, + public readonly createdAt: Date, + public readonly updatedAt: Date, + public readonly deletedAt?: Date, + ) {} + + static create(props: { + content?: string; + order: number; + cardsetId: number; + }): Card { + return new Card( + 0, + props.content ?? '', + props.order, + props.cardsetId, + new Date(), + new Date(), + ); + } + + updateContent(content: string): Card { + return new Card( + this.id, + content, + this.order, + this.cardsetId, + this.createdAt, + new Date(), + this.deletedAt, + ); + } + + changeOrder(order: number): Card { + return new Card( + this.id, + this.content, + order, + this.cardsetId, + this.createdAt, + new Date(), + this.deletedAt, + ); + } +} diff --git a/src/cardset/domain/model/cardset-manager.ts b/src/cardset/domain/model/cardset-manager.ts new file mode 100644 index 0000000..6e66d37 --- /dev/null +++ b/src/cardset/domain/model/cardset-manager.ts @@ -0,0 +1,11 @@ +export class CardsetManager { + constructor( + public readonly id: number, + public readonly userId: number, + public readonly cardSetId: number, + ) {} + + static create(props: { userId: number; cardSetId: number }): CardsetManager { + return new CardsetManager(0, props.userId, props.cardSetId); + } +} diff --git a/src/cardset/domain/model/cardset.ts b/src/cardset/domain/model/cardset.ts new file mode 100644 index 0000000..1f42390 --- /dev/null +++ b/src/cardset/domain/model/cardset.ts @@ -0,0 +1,76 @@ +export class Cardset { + constructor( + public readonly id: number, + public name: string, + public groupId: number, + public publicVisible: boolean, + public category: string, + public hashtag: string | null, + public imageUrl: string, + public cardCount: number, + public readonly createdAt: Date, + public readonly updatedAt: Date, + public readonly deletedAt?: Date, + ) {} + + static create(props: { + name: string; + groupId: number; + publicVisible: boolean; + category: string; + hashtag?: string | null; + imageUrl: string; + cardCount?: number; + }): Cardset { + return new Cardset( + 0, + props.name, + props.groupId, + props.publicVisible, + props.category, + props.hashtag ?? null, + props.imageUrl, + props.cardCount ?? 10, + new Date(), + new Date(), + ); + } + + updateInfo(props: Partial<{ + name: string; + publicVisible: boolean; + category: string; + hashtag: string | null; + imageUrl: string; + }>): Cardset { + return new Cardset( + this.id, + props.name ?? this.name, + this.groupId, + props.publicVisible ?? this.publicVisible, + props.category ?? this.category, + props.hashtag !== undefined ? props.hashtag : this.hashtag, + props.imageUrl ?? this.imageUrl, + this.cardCount, + this.createdAt, + new Date(), + this.deletedAt, + ); + } + + changeCardCount(newCount: number): Cardset { + return new Cardset( + this.id, + this.name, + this.groupId, + this.publicVisible, + this.category, + this.hashtag, + this.imageUrl, + newCount, + this.createdAt, + new Date(), + this.deletedAt, + ); + } +} diff --git a/src/cardset/domain/repository/card.repository.ts b/src/cardset/domain/repository/card.repository.ts new file mode 100644 index 0000000..02875dd --- /dev/null +++ b/src/cardset/domain/repository/card.repository.ts @@ -0,0 +1,12 @@ +import { Card } from '../model/card'; + +export const CARD_REPOSITORY = Symbol('CARD_REPOSITORY'); + +export interface ICardRepository { + findById(id: number): Promise; + findAllByCardsetId(cardsetId: number): Promise; + save(card: Card): Promise; + update(id: number, card: Partial): Promise; + delete(id: number): Promise; + updateOrder(cardId: number, order: number): Promise; +} diff --git a/src/cardset/domain/repository/cardset.repository.ts b/src/cardset/domain/repository/cardset.repository.ts new file mode 100644 index 0000000..c6f1a97 --- /dev/null +++ b/src/cardset/domain/repository/cardset.repository.ts @@ -0,0 +1,11 @@ +import { Cardset } from '../model/cardset'; + +export const CARDSET_REPOSITORY = Symbol('CARDSET_REPOSITORY'); + +export interface ICardsetRepository { + findAll(): Promise; + findById(id: number): Promise; + save(cardset: Cardset): Promise; + update(id: number, cardset: Partial): Promise; + delete(id: number): Promise; +} diff --git a/src/cardset/domain/service/cardset-card.domain-service.ts b/src/cardset/domain/service/cardset-card.domain-service.ts new file mode 100644 index 0000000..09e01d6 --- /dev/null +++ b/src/cardset/domain/service/cardset-card.domain-service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { Card } from '../model/card'; + +/** + * 카드셋과 카드 사이의 도메인 규칙을 담당 + * - 카드 수 조정 시 어떤 카드를 추가/삭제할지 결정 + */ +@Injectable() +export class CardsetCardDomainService { + buildCardsToAdd(cardsetId: number, currentCount: number, targetCount: number): Card[] { + const cards: Card[] = []; + for (let i = currentCount; i < targetCount; i++) { + cards.push(Card.create({ content: '', order: i, cardsetId })); + } + return cards; + } + + selectCardsToRemove(cards: Card[], targetCount: number): Card[] { + return cards.slice(targetCount); + } +} diff --git a/src/cardset/infrastructure/http/card.controller.ts b/src/cardset/infrastructure/http/card.controller.ts new file mode 100644 index 0000000..4296ea5 --- /dev/null +++ b/src/cardset/infrastructure/http/card.controller.ts @@ -0,0 +1,68 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Headers, +} from '@nestjs/common'; +import { CardUseCase } from '../../application/card.use-case'; +import { CreateCardDto } from '../../application/dto/create-card.dto'; +import { UpdateCardDto } from '../../application/dto/update-card.dto'; +import { Card } from '../../domain/model/card'; + +@Controller('cards') +export class CardController { + constructor(private readonly cardUseCase: CardUseCase) {} + + @Post() + async create( + @Headers('X-USER-ID') _userId: string, + @Body() dto: CreateCardDto, + ): Promise { + return this.cardUseCase.create(dto); + } + + @Get('cardset/:cardsetId') + async findByCardsetId( + @Headers('X-USER-ID') _userId: string, + @Param('cardsetId') cardsetId: string, + ): Promise { + return this.cardUseCase.findAllByCardsetId(parseInt(cardsetId)); + } + + @Get(':id') + async findOne( + @Headers('X-USER-ID') _userId: string, + @Param('id') id: string, + ): Promise { + return this.cardUseCase.findOne(parseInt(id)); + } + + @Put('reorder') + async reorderCards( + @Headers('X-USER-ID') _userId: string, + @Body() cardOrders: { cardId: number; order: number }[], + ): Promise { + return this.cardUseCase.reorderCards(cardOrders); + } + + @Put(':id') + async update( + @Headers('X-USER-ID') _userId: string, + @Param('id') id: string, + @Body() dto: UpdateCardDto, + ): Promise { + return this.cardUseCase.update(parseInt(id), dto); + } + + @Delete(':id') + async remove( + @Headers('X-USER-ID') _userId: string, + @Param('id') id: string, + ): Promise { + return this.cardUseCase.remove(parseInt(id)); + } +} diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts new file mode 100644 index 0000000..62c1878 --- /dev/null +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -0,0 +1,66 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Headers, +} from '@nestjs/common'; +import { CardsetUseCase } from '../../application/cardset.use-case'; +import { CreateCardsetDto } from '../../application/dto/create-cardset.dto'; +import { UpdateCardsetDto } from '../../application/dto/update-cardset.dto'; +import { Cardset } from '../../domain/model/cardset'; + +@Controller('cardsets') +export class CardsetController { + constructor(private readonly cardsetUseCase: CardsetUseCase) {} + + @Post() + async create( + @Headers('X-USER-ID') _userId: string, + @Body() dto: CreateCardsetDto, + ): Promise { + return this.cardsetUseCase.create(dto); + } + + @Get() + async findAll(@Headers('X-USER-ID') _userId: string): Promise { + return this.cardsetUseCase.findAll(); + } + + @Get(':id') + async findOne( + @Headers('X-USER-ID') _userId: string, + @Param('id') id: string, + ): Promise { + return this.cardsetUseCase.findOne(parseInt(id)); + } + + @Put(':id') + async update( + @Headers('X-USER-ID') _userId: string, + @Param('id') id: string, + @Body() dto: UpdateCardsetDto, + ): Promise { + return this.cardsetUseCase.update(parseInt(id), dto); + } + + @Delete(':id') + async remove( + @Headers('X-USER-ID') _userId: string, + @Param('id') id: string, + ): Promise { + return this.cardsetUseCase.remove(parseInt(id)); + } + + @Put(':id/card-count') + async updateCardCount( + @Headers('X-USER-ID') _userId: string, + @Param('id') id: string, + @Body() body: { cardCount: number }, + ): Promise { + return this.cardsetUseCase.updateCardCount(parseInt(id), body.cardCount); + } +} diff --git a/src/cardset/infrastructure/persistence/card.repository.impl.ts b/src/cardset/infrastructure/persistence/card.repository.impl.ts new file mode 100644 index 0000000..747096c --- /dev/null +++ b/src/cardset/infrastructure/persistence/card.repository.impl.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ICardRepository } from '../../domain/repository/card.repository'; +import { Card } from '../../domain/model/card'; +import { CardOrmEntity } from './orm/card.orm-entity'; +import { CardMapper } from './mapper/card.mapper'; + +@Injectable() +export class CardRepositoryImpl implements ICardRepository { + constructor( + @InjectRepository(CardOrmEntity) + private readonly ormRepository: Repository, + ) {} + + async findById(id: number): Promise { + const orm = await this.ormRepository.findOne({ where: { id } }); + return orm ? CardMapper.toDomain(orm) : null; + } + + async findAllByCardsetId(cardsetId: number): Promise { + const orms = await this.ormRepository.find({ + where: { cardsetId }, + order: { order: 'ASC' }, + }); + return orms.map(CardMapper.toDomain); + } + + async save(card: Card): Promise { + const ormData = CardMapper.toOrm(card); + const created = this.ormRepository.create(ormData); + const saved = await this.ormRepository.save(created); + return CardMapper.toDomain(saved); + } + + async update(id: number, card: Partial): Promise { + await this.ormRepository.update(id, CardMapper.toOrm(card as Card)); + return this.findById(id); + } + + async delete(id: number): Promise { + await this.ormRepository.delete(id); + } + + async updateOrder(cardId: number, order: number): Promise { + await this.ormRepository.update(cardId, { order }); + } +} diff --git a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts new file mode 100644 index 0000000..00a0545 --- /dev/null +++ b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ICardsetRepository } from '../../domain/repository/cardset.repository'; +import { Cardset } from '../../domain/model/cardset'; +import { CardsetOrmEntity } from './orm/cardset.orm-entity'; +import { CardsetMapper } from './mapper/cardset.mapper'; + +@Injectable() +export class CardsetRepositoryImpl implements ICardsetRepository { + constructor( + @InjectRepository(CardsetOrmEntity) + private readonly ormRepository: Repository, + ) {} + + async findAll(): Promise { + const orms = await this.ormRepository.find({ + order: { createdAt: 'DESC' }, + }); + return orms.map(CardsetMapper.toDomain); + } + + async findById(id: number): Promise { + const orm = await this.ormRepository.findOne({ where: { id } }); + return orm ? CardsetMapper.toDomain(orm) : null; + } + + async save(cardset: Cardset): Promise { + const ormData = CardsetMapper.toOrm(cardset); + const created = this.ormRepository.create(ormData); + const saved = await this.ormRepository.save(created); + return CardsetMapper.toDomain(saved); + } + + async update(id: number, cardset: Partial): Promise { + await this.ormRepository.update(id, CardsetMapper.toOrm(cardset as Cardset)); + return this.findById(id); + } + + async delete(id: number): Promise { + await this.ormRepository.delete(id); + } +} diff --git a/src/cardset/infrastructure/persistence/mapper/card.mapper.ts b/src/cardset/infrastructure/persistence/mapper/card.mapper.ts new file mode 100644 index 0000000..b82a5f3 --- /dev/null +++ b/src/cardset/infrastructure/persistence/mapper/card.mapper.ts @@ -0,0 +1,25 @@ +import { Card } from '../../../domain/model/card'; +import { CardOrmEntity } from '../orm/card.orm-entity'; + +export class CardMapper { + static toDomain(orm: CardOrmEntity): Card { + return new Card( + orm.id, + orm.content, + orm.order, + orm.cardsetId, + orm.createdAt, + orm.updatedAt, + orm.deletedAt, + ); + } + + static toOrm(domain: Card): Partial { + return { + id: domain.id || undefined, + content: domain.content, + order: domain.order, + cardsetId: domain.cardsetId, + }; + } +} diff --git a/src/cardset/infrastructure/persistence/mapper/cardset.mapper.ts b/src/cardset/infrastructure/persistence/mapper/cardset.mapper.ts new file mode 100644 index 0000000..076860e --- /dev/null +++ b/src/cardset/infrastructure/persistence/mapper/cardset.mapper.ts @@ -0,0 +1,33 @@ +import { Cardset } from '../../../domain/model/cardset'; +import { CardsetOrmEntity } from '../orm/cardset.orm-entity'; + +export class CardsetMapper { + static toDomain(orm: CardsetOrmEntity): Cardset { + return new Cardset( + orm.id, + orm.name, + orm.groupId, + orm.publicVisible, + orm.category, + orm.hashtag ?? null, + orm.imageUrl, + orm.cardCount, + orm.createdAt, + orm.updatedAt, + orm.deletedAt, + ); + } + + static toOrm(domain: Cardset): Partial { + return { + id: domain.id || undefined, + name: domain.name, + groupId: domain.groupId, + publicVisible: domain.publicVisible, + category: domain.category, + hashtag: domain.hashtag, + imageUrl: domain.imageUrl, + cardCount: domain.cardCount, + }; + } +} diff --git a/src/cardset/infrastructure/persistence/orm/card.orm-entity.ts b/src/cardset/infrastructure/persistence/orm/card.orm-entity.ts new file mode 100644 index 0000000..4af0be2 --- /dev/null +++ b/src/cardset/infrastructure/persistence/orm/card.orm-entity.ts @@ -0,0 +1,28 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { BaseOrmEntity } from '../../../../shared/infrastructure/persistence/base.orm-entity'; +import { CardsetOrmEntity } from './cardset.orm-entity'; + +@Entity('cards') +export class CardOrmEntity extends BaseOrmEntity { + @PrimaryGeneratedColumn() + id!: number; + + @Column('text') + content!: string; + + @Column({ default: 0 }) + order!: number; + + @Column() + cardsetId!: number; + + @ManyToOne(() => CardsetOrmEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'cardsetId' }) + cardset!: CardsetOrmEntity; +} diff --git a/src/cardset-manager/entities/cardset-manager.entity.ts b/src/cardset/infrastructure/persistence/orm/cardset-manager.orm-entity.ts similarity index 92% rename from src/cardset-manager/entities/cardset-manager.entity.ts rename to src/cardset/infrastructure/persistence/orm/cardset-manager.orm-entity.ts index 823437b..a29a8c2 100644 --- a/src/cardset-manager/entities/cardset-manager.entity.ts +++ b/src/cardset/infrastructure/persistence/orm/cardset-manager.orm-entity.ts @@ -4,7 +4,7 @@ import { Entity, PrimaryGeneratedColumn, Column, Index, Unique } from 'typeorm'; @Unique(['userId', 'cardSetId']) @Index('idx_card_set_manager_user', ['userId']) @Index('idx_card_set_manager_cardset', ['cardSetId']) -export class CardsetManager { +export class CardsetManagerOrmEntity { @PrimaryGeneratedColumn({ type: 'int' }) id!: number; diff --git a/src/cardset/entities/cardset.entity.ts b/src/cardset/infrastructure/persistence/orm/cardset.orm-entity.ts similarity index 83% rename from src/cardset/entities/cardset.entity.ts rename to src/cardset/infrastructure/persistence/orm/cardset.orm-entity.ts index edc1397..1d6eee5 100644 --- a/src/cardset/entities/cardset.entity.ts +++ b/src/cardset/infrastructure/persistence/orm/cardset.orm-entity.ts @@ -1,8 +1,8 @@ import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; -import { BaseEntity } from '../../common/entities/base.entity'; +import { BaseOrmEntity } from '../../../../shared/infrastructure/persistence/base.orm-entity'; @Entity('card_sets') -export class Cardset extends BaseEntity { +export class CardsetOrmEntity extends BaseOrmEntity { @PrimaryGeneratedColumn({ type: 'int' }) id!: number; diff --git a/src/collaboration/application/collaboration.use-case.ts b/src/collaboration/application/collaboration.use-case.ts new file mode 100644 index 0000000..7b87d3c --- /dev/null +++ b/src/collaboration/application/collaboration.use-case.ts @@ -0,0 +1,98 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import * as Y from 'yjs'; +import { YJS_DOCUMENT_REPOSITORY } from '../domain/repository/yjs-document.repository'; +import type { IYjsDocumentRepository } from '../domain/repository/yjs-document.repository'; +import { YjsDocument } from '../domain/model/yjs-document'; + +@Injectable() +export class CollaborationUseCase { + private readonly logger = new Logger(CollaborationUseCase.name); + private documentCache = new Map(); + + constructor( + @Inject(YJS_DOCUMENT_REPOSITORY) + private readonly yjsDocumentRepository: IYjsDocumentRepository, + ) {} + + async getOrCreateDocument(cardsetId: number): Promise { + if (this.documentCache.has(cardsetId)) { + return this.documentCache.get(cardsetId)!; + } + + const stored = await this.yjsDocumentRepository.findByCardsetId(cardsetId); + let doc: Y.Doc; + + if (stored) { + doc = new Y.Doc(); + Y.applyUpdate(doc, stored.documentData); + this.logger.log(`Loaded Yjs document for cardset ${cardsetId} from DB`); + } else { + doc = new Y.Doc(); + await this.persistDocument(cardsetId, doc); + this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); + } + + doc.on('update', async () => { + await this.persistDocument(cardsetId, doc); + }); + + this.documentCache.set(cardsetId, doc); + return doc; + } + + async applyUpdate(cardsetId: number, update: number[]): Promise { + const doc = await this.getOrCreateDocument(cardsetId); + Y.applyUpdate(doc, new Uint8Array(update)); + return Y.encodeStateAsUpdate(doc); + } + + async getState(cardsetId: number): Promise { + const doc = await this.getOrCreateDocument(cardsetId); + return Y.encodeStateAsUpdate(doc); + } + + async syncCardsFromDB(cardsetId: number, cards: any[]): Promise { + const doc = await this.getOrCreateDocument(cardsetId); + const cardsArray = doc.getArray('cards'); + + cardsArray.delete(0, cardsArray.length); + + const yjsCards = cards.map((card) => ({ + id: card.id, + content: card.content, + order: card.order, + cardsetId: card.cardsetId, + createdAt: card.createdAt?.toISOString(), + updatedAt: card.updatedAt?.toISOString(), + })); + + cardsArray.insert(0, yjsCards); + this.logger.log(`Synced ${cards.length} cards to Yjs for cardset ${cardsetId}`); + } + + async deleteDocument(cardsetId: number): Promise { + await this.yjsDocumentRepository.deleteByCardsetId(cardsetId); + this.documentCache.delete(cardsetId); + this.logger.log(`Deleted Yjs document for cardset ${cardsetId}`); + } + + private async persistDocument(cardsetId: number, doc: Y.Doc): Promise { + try { + const state = Y.encodeStateAsUpdate(doc); + const stored = await this.yjsDocumentRepository.findByCardsetId(cardsetId); + + if (stored) { + const updated = stored.withNewData(Buffer.from(state)); + await this.yjsDocumentRepository.update(stored.id, updated); + } else { + const newDoc = YjsDocument.create({ + cardsetId, + documentData: Buffer.from(state), + }); + await this.yjsDocumentRepository.save(newDoc); + } + } catch (error) { + this.logger.error(`Failed to persist Yjs document for cardset ${cardsetId}:`, error); + } + } +} diff --git a/src/collaboration/collaboration.module.ts b/src/collaboration/collaboration.module.ts new file mode 100644 index 0000000..718909e --- /dev/null +++ b/src/collaboration/collaboration.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { YjsDocumentOrmEntity } from './infrastructure/persistence/orm/yjs-document.orm-entity'; +import { YjsDocumentRepositoryImpl } from './infrastructure/persistence/yjs-document.repository.impl'; +import { YJS_DOCUMENT_REPOSITORY } from './domain/repository/yjs-document.repository'; +import { CollaborationUseCase } from './application/collaboration.use-case'; +import { CollaborationGateway } from './infrastructure/gateway/collaboration.gateway'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([YjsDocumentOrmEntity]), AuthModule], + providers: [ + { provide: YJS_DOCUMENT_REPOSITORY, useClass: YjsDocumentRepositoryImpl }, + CollaborationUseCase, + CollaborationGateway, + ], +}) +export class CollaborationModule {} diff --git a/src/collaboration/domain/model/yjs-document.ts b/src/collaboration/domain/model/yjs-document.ts new file mode 100644 index 0000000..e4f9759 --- /dev/null +++ b/src/collaboration/domain/model/yjs-document.ts @@ -0,0 +1,32 @@ +export class YjsDocument { + constructor( + public readonly id: number, + public readonly cardsetId: number, + public documentData: Buffer, + public version: number, + public readonly createdAt: Date, + public readonly updatedAt: Date, + ) {} + + static create(props: { cardsetId: number; documentData: Buffer }): YjsDocument { + return new YjsDocument( + 0, + props.cardsetId, + props.documentData, + 1, + new Date(), + new Date(), + ); + } + + withNewData(documentData: Buffer): YjsDocument { + return new YjsDocument( + this.id, + this.cardsetId, + documentData, + this.version + 1, + this.createdAt, + new Date(), + ); + } +} diff --git a/src/collaboration/domain/repository/yjs-document.repository.ts b/src/collaboration/domain/repository/yjs-document.repository.ts new file mode 100644 index 0000000..be0eed6 --- /dev/null +++ b/src/collaboration/domain/repository/yjs-document.repository.ts @@ -0,0 +1,10 @@ +import { YjsDocument } from '../model/yjs-document'; + +export const YJS_DOCUMENT_REPOSITORY = Symbol('YJS_DOCUMENT_REPOSITORY'); + +export interface IYjsDocumentRepository { + findByCardsetId(cardsetId: number): Promise; + save(document: YjsDocument): Promise; + update(id: number, document: Partial): Promise; + deleteByCardsetId(cardsetId: number): Promise; +} diff --git a/src/collaboration/infrastructure/gateway/collaboration.gateway.ts b/src/collaboration/infrastructure/gateway/collaboration.gateway.ts new file mode 100644 index 0000000..f4a0bbd --- /dev/null +++ b/src/collaboration/infrastructure/gateway/collaboration.gateway.ts @@ -0,0 +1,158 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + ConnectedSocket, + MessageBody, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Logger, UseGuards } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; +import { WsAuthGuard } from '../../../auth/infrastructure/guard/ws-auth.guard'; +import { WsUser } from '../../../shared/decorator/ws-user.decorator'; +import type { UserAuth } from '../../../shared/types/user-auth.type'; +import { CollaborationUseCase } from '../../application/collaboration.use-case'; + +@UseGuards(WsAuthGuard) +@WebSocketGateway({ + cors: { origin: '*' }, + namespace: '/cardsets', + pingTimeout: 60000, + pingInterval: 25000, +}) +export class CollaborationGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server!: Server; + + private readonly logger = new Logger(CollaborationGateway.name); + + constructor(private readonly collaborationUseCase: CollaborationUseCase) {} + + handleConnection(client: Socket) { + this.logger.log(`Client connected: ${client.id}`); + } + + handleDisconnect(client: Socket) { + this.logger.log(`Client disconnected: ${client.id}`); + } + + @SubscribeMessage('join-cardset') + async handleJoinCardset( + @WsUser() user: UserAuth, + @ConnectedSocket() client: Socket, + @MessageBody() data: { cardsetId: string }, + ) { + try { + const { cardsetId } = data; + this.logger.log(`User ${user.userId} joining cardset ${cardsetId}`); + + void client.join(`cardset:${cardsetId}`); + + const state = await this.collaborationUseCase.getState(parseInt(cardsetId)); + client.emit('sync-response', { + cardsetId, + update: Array.from(state), + }); + + this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); + } catch (error) { + this.logger.error('Error joining cardset:', error); + client.emit('error', { message: 'Failed to join cardset' }); + } + } + + @SubscribeMessage('leave-cardset') + async handleLeaveCardset( + @WsUser() user: UserAuth, + @ConnectedSocket() client: Socket, + @MessageBody() data: { cardsetId: string }, + ) { + try { + void client.leave(`cardset:${data.cardsetId}`); + this.logger.log(`User ${user.userId} left cardset ${data.cardsetId}`); + } catch (error) { + this.logger.error('Error leaving cardset:', error); + } + } + + @SubscribeMessage('sync') + async handleSync( + @WsUser() user: UserAuth, + @ConnectedSocket() client: Socket, + @MessageBody() + data: { cardsetId: string; update?: number[] }, + ) { + try { + const { cardsetId, update } = data; + this.logger.log(`Sync request from user ${user.userId} for cardset ${cardsetId}`); + + let state: Uint8Array; + if (update) { + state = await this.collaborationUseCase.applyUpdate(parseInt(cardsetId), update); + } else { + state = await this.collaborationUseCase.getState(parseInt(cardsetId)); + } + + client.emit('sync-response', { + cardsetId, + update: Array.from(state), + }); + } catch (error) { + this.logger.error('Error during sync:', error); + client.emit('error', { message: 'Sync failed' }); + } + } + + @SubscribeMessage('update-card') + async handleUpdateCard( + @WsUser() user: UserAuth, + @ConnectedSocket() client: Socket, + @MessageBody() + data: { + cardsetId: string; + cardId: string; + updates: Partial<{ content: string; order: number }>; + }, + ) { + try { + const { cardsetId } = data; + const state = await this.collaborationUseCase.getState(parseInt(cardsetId)); + + this.server.to(`cardset:${cardsetId}`).emit('sync-response', { + cardsetId, + update: Array.from(state), + }); + + this.logger.log(`User ${user.userId} updated card in cardset ${cardsetId}`); + } catch (error) { + this.logger.error('Error updating card:', error); + client.emit('error', { message: 'Failed to update card' }); + } + } + + @SubscribeMessage('reorder-cards') + async handleReorderCards( + @WsUser() user: UserAuth, + @ConnectedSocket() client: Socket, + @MessageBody() + data: { cardsetId: string; cardOrders: { cardId: string; order: number }[] }, + ) { + try { + const { cardsetId } = data; + const state = await this.collaborationUseCase.getState(parseInt(cardsetId)); + + this.server.to(`cardset:${cardsetId}`).emit('sync-response', { + cardsetId, + update: Array.from(state), + }); + + this.logger.log(`User ${user.userId} reordered cards in cardset ${cardsetId}`); + } catch (error) { + this.logger.error('Error reordering cards:', error); + client.emit('error', { message: 'Failed to reorder cards' }); + } + } +} diff --git a/src/collaboration/infrastructure/persistence/mapper/yjs-document.mapper.ts b/src/collaboration/infrastructure/persistence/mapper/yjs-document.mapper.ts new file mode 100644 index 0000000..1b97ec0 --- /dev/null +++ b/src/collaboration/infrastructure/persistence/mapper/yjs-document.mapper.ts @@ -0,0 +1,24 @@ +import { YjsDocument } from '../../../domain/model/yjs-document'; +import { YjsDocumentOrmEntity } from '../orm/yjs-document.orm-entity'; + +export class YjsDocumentMapper { + static toDomain(orm: YjsDocumentOrmEntity): YjsDocument { + return new YjsDocument( + orm.id, + orm.cardsetId, + orm.documentData, + orm.version, + orm.createdAt, + orm.updatedAt, + ); + } + + static toOrm(domain: YjsDocument): Partial { + return { + id: domain.id || undefined, + cardsetId: domain.cardsetId, + documentData: domain.documentData, + version: domain.version, + }; + } +} diff --git a/src/websocket/entities/yjs-document.entity.ts b/src/collaboration/infrastructure/persistence/orm/yjs-document.orm-entity.ts similarity index 74% rename from src/websocket/entities/yjs-document.entity.ts rename to src/collaboration/infrastructure/persistence/orm/yjs-document.orm-entity.ts index 7f6c5d4..f66c8c4 100644 --- a/src/websocket/entities/yjs-document.entity.ts +++ b/src/collaboration/infrastructure/persistence/orm/yjs-document.orm-entity.ts @@ -1,7 +1,13 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; @Entity('yjs_documents') -export class YjsDocument { +export class YjsDocumentOrmEntity { @PrimaryGeneratedColumn() id!: number; diff --git a/src/collaboration/infrastructure/persistence/yjs-document.repository.impl.ts b/src/collaboration/infrastructure/persistence/yjs-document.repository.impl.ts new file mode 100644 index 0000000..70624cd --- /dev/null +++ b/src/collaboration/infrastructure/persistence/yjs-document.repository.impl.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { IYjsDocumentRepository } from '../../domain/repository/yjs-document.repository'; +import { YjsDocument } from '../../domain/model/yjs-document'; +import { YjsDocumentOrmEntity } from './orm/yjs-document.orm-entity'; +import { YjsDocumentMapper } from './mapper/yjs-document.mapper'; + +@Injectable() +export class YjsDocumentRepositoryImpl implements IYjsDocumentRepository { + constructor( + @InjectRepository(YjsDocumentOrmEntity) + private readonly ormRepository: Repository, + ) {} + + async findByCardsetId(cardsetId: number): Promise { + const orm = await this.ormRepository.findOne({ where: { cardsetId } }); + return orm ? YjsDocumentMapper.toDomain(orm) : null; + } + + async save(document: YjsDocument): Promise { + const ormData = YjsDocumentMapper.toOrm(document); + const created = this.ormRepository.create(ormData); + const saved = await this.ormRepository.save(created); + return YjsDocumentMapper.toDomain(saved); + } + + async update(id: number, document: Partial): Promise { + await this.ormRepository.update(id, { + documentData: document.documentData, + version: document.version, + }); + } + + async deleteByCardsetId(cardsetId: number): Promise { + await this.ormRepository.delete({ cardsetId }); + } +} diff --git a/src/decorators/auth-user.decorator.ts b/src/decorators/auth-user.decorator.ts deleted file mode 100644 index d0675f7..0000000 --- a/src/decorators/auth-user.decorator.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { UserAuth } from '../types/userAuth.type'; -import { Request } from 'express'; - -export const AuthUser = createParamDecorator( - (data: unknown, ctx: ExecutionContext): UserAuth => { - const request = ctx.switchToHttp().getRequest(); - return request.user; - }, -); diff --git a/src/decorators/ws-user.decorator.ts b/src/decorators/ws-user.decorator.ts deleted file mode 100644 index c81d024..0000000 --- a/src/decorators/ws-user.decorator.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; - -export const WsUser = createParamDecorator( - (data: unknown, ctx: ExecutionContext) => { - const client = ctx.switchToWs().getClient<{ data: { user?: any } }>(); - return client.data?.user; - }, -); \ No newline at end of file diff --git a/src/config/authConfig.ts b/src/shared/config/auth.config.ts similarity index 62% rename from src/config/authConfig.ts rename to src/shared/config/auth.config.ts index 83c4df4..661fae6 100644 --- a/src/config/authConfig.ts +++ b/src/shared/config/auth.config.ts @@ -1,5 +1,5 @@ import { registerAs } from '@nestjs/config'; export default registerAs('auth', () => ({ - jwtSecret: process.env.JWT_SECRET || 'defaultSecret', + jwtSecret: process.env.JWT_SECRET, })); diff --git a/src/shared/decorator/ws-user.decorator.ts b/src/shared/decorator/ws-user.decorator.ts new file mode 100644 index 0000000..f3363bd --- /dev/null +++ b/src/shared/decorator/ws-user.decorator.ts @@ -0,0 +1,10 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { UserAuth } from '../types/user-auth.type'; +import { Socket } from 'socket.io'; + +export const WsUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): UserAuth => { + const client = ctx.switchToWs().getClient(); + return (client.data as { user: UserAuth }).user; + }, +); diff --git a/src/common/entities/base.entity.ts b/src/shared/infrastructure/persistence/base.orm-entity.ts similarity index 87% rename from src/common/entities/base.entity.ts rename to src/shared/infrastructure/persistence/base.orm-entity.ts index ff458ff..0ba11c4 100644 --- a/src/common/entities/base.entity.ts +++ b/src/shared/infrastructure/persistence/base.orm-entity.ts @@ -1,6 +1,6 @@ import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; -export abstract class BaseEntity { +export abstract class BaseOrmEntity { @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; diff --git a/src/types/userAuth.type.ts b/src/shared/types/user-auth.type.ts similarity index 97% rename from src/types/userAuth.type.ts rename to src/shared/types/user-auth.type.ts index d4cb0ad..bf82ae7 100644 --- a/src/types/userAuth.type.ts +++ b/src/shared/types/user-auth.type.ts @@ -2,4 +2,4 @@ export interface UserAuth { userId: string; role: string; tokenVersion: number; -} \ No newline at end of file +} diff --git a/src/types/express.d.ts b/src/types/express.d.ts deleted file mode 100644 index ee41420..0000000 --- a/src/types/express.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'express'; -import { UserAuth } from './userAuth.type'; - -declare module 'express' { - export interface Request { - user: UserAuth; - } -} diff --git a/src/websocket/websocket.gateway.ts b/src/websocket/websocket.gateway.ts deleted file mode 100644 index 62fa4be..0000000 --- a/src/websocket/websocket.gateway.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { - WebSocketGateway, - WebSocketServer, - SubscribeMessage, - ConnectedSocket, - MessageBody, - OnGatewayConnection, - OnGatewayDisconnect, -} from '@nestjs/websockets'; -import { Logger, UseGuards } from '@nestjs/common'; -import { Server, Socket } from 'socket.io'; -import * as Y from 'yjs'; -import { WsAuthGuard } from '../auth/ws-auth.guard'; -import { WsUser } from '../decorators/ws-user.decorator'; -import type { UserAuth } from '../types/userAuth.type'; - -@UseGuards(WsAuthGuard) // 인증 가드 적용 -@WebSocketGateway({ - cors: { - origin: '*', - }, - namespace: '/cardsets', - pingTimeout: 60000, // 60초 - pingInterval: 25000, // 25초 -}) -export class CollaborationGateway implements OnGatewayConnection, OnGatewayDisconnect { - @WebSocketServer() - server: Server; - - private readonly logger = new Logger(CollaborationGateway.name); - private documentMap = new Map(); // cardsetId -> Y.Doc - - constructor() {} - - handleConnection(client: Socket) { - this.logger.log(`Client connected: ${client.id}`); - } - - handleDisconnect(client: Socket) { - this.logger.log(`Client disconnected: ${client.id}`); - // 클라이언트가 연결된 모든 카드셋에서 나가기 - for (const [cardsetId, doc] of this.documentMap) { - void client.leave(`cardset:${cardsetId}`); - } - } - - // 카드셋에 조인 (카드셋의 Yjs 문서에 접근) - @SubscribeMessage('join-cardset') - async handleJoinCardset( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() data: { cardsetId: string }, - ) { - try { - const { cardsetId } = data; - this.logger.log(`User ${user.userId} joining cardset ${cardsetId}`); - - // 카드셋 룸에 조인 - void client.join(`cardset:${cardsetId}`); - - // 카드셋의 Yjs 문서 가져오기 또는 생성 - let doc = this.documentMap.get(cardsetId); - if (!doc) { - doc = new Y.Doc(); - this.documentMap.set(cardsetId, doc); - this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); - } - - // 클라이언트에게 현재 카드셋 상태 전송 - const cards = doc.getArray('cards'); - client.emit('cardset-state', { - cardsetId, - cards: cards.toArray(), - }); - - this.logger.log(`User ${user.userId} joined cardset ${cardsetId}`); - } catch (error) { - this.logger.error('Error joining cardset:', error); - client.emit('error', { message: 'Failed to join cardset' }); - } - } - - // 카드셋에서 나가기 - @SubscribeMessage('leave-cardset') - async handleLeaveCardset( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() data: { cardsetId: string }, - ) { - try { - const { cardsetId } = data; - this.logger.log(`User ${user.userId} leaving cardset ${cardsetId}`); - - void client.leave(`cardset:${cardsetId}`); - this.logger.log(`User ${user.userId} left cardset ${cardsetId}`); - } catch (error) { - this.logger.error('Error leaving cardset:', error); - } - } - - - // 카드 업데이트 - @SubscribeMessage('update-card') - async handleUpdateCard( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() data: { cardsetId: string; cardId: string; updates: Partial<{ content: string; order: number }> }, - ) { - try { - const { cardsetId, cardId, updates } = data; - this.logger.log(`User ${user.userId} updating card ${cardId} in cardset ${cardsetId}`); - - const doc = this.documentMap.get(cardsetId); - if (!doc) { - client.emit('error', { message: 'Cardset not found' }); - return; - } - - const cards = doc.getArray('cards'); - const cardsArray = cards.toArray(); - const cardIndex = cardsArray.findIndex((card: any) => card.id === cardId); - - if (cardIndex === -1) { - client.emit('error', { message: 'Card not found' }); - return; - } - - const currentCard = cardsArray[cardIndex] as any; - const updatedCard = { - ...currentCard, - ...updates, - updatedAt: new Date().toISOString(), - }; - - cards.delete(cardIndex, 1); - cards.insert(cardIndex, [updatedCard]); - - this.logger.log(`Card ${cardId} updated in cardset ${cardsetId}`); - } catch (error) { - this.logger.error('Error updating card:', error); - client.emit('error', { message: 'Failed to update card' }); - } - } - - - // Yjs 동기화 (클라이언트가 변경사항을 받을 때) - @SubscribeMessage('sync') - async handleSync( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() data: { cardsetId: string; syncStep: number; update?: number[] }, - ) { - try { - const { cardsetId, syncStep, update } = data; - this.logger.log(`Sync request from user ${user.userId} for cardset ${cardsetId}`); - - const doc = this.documentMap.get(cardsetId); - if (!doc) { - client.emit('error', { message: 'Cardset not found' }); - return; - } - - if (update) { - // 클라이언트에서 온 업데이트 적용 - Y.applyUpdate(doc, new Uint8Array(update)); - } - - // 현재 상태를 클라이언트에게 전송 - const state = Y.encodeStateAsUpdate(doc); - client.emit('sync-response', { - cardsetId, - update: Array.from(state), - }); - } catch (error) { - this.logger.error('Error during sync:', error); - client.emit('error', { message: 'Sync failed' }); - } - } - - // 카드 순서 변경 - @SubscribeMessage('reorder-cards') - async handleReorderCards( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() data: { cardsetId: string; cardOrders: { cardId: string; order: number }[] }, - ) { - try { - const { cardsetId, cardOrders } = data; - this.logger.log(`User ${user.userId} reordering cards in cardset ${cardsetId}`); - - const doc = this.documentMap.get(cardsetId); - if (!doc) { - client.emit('error', { message: 'Cardset not found' }); - return; - } - - const cards = doc.getArray('cards'); - const cardsArray = cards.toArray(); - - // 순서 업데이트 - cardOrders.forEach(({ cardId, order }) => { - const cardIndex = cardsArray.findIndex((card: any) => card.id === cardId); - if (cardIndex !== -1) { - const currentCard = cardsArray[cardIndex] as any; - cardsArray[cardIndex] = { - ...currentCard, - order, - updatedAt: new Date().toISOString(), - }; - } - }); - - // 정렬된 순서로 다시 설정 - cardsArray.sort((a: any, b: any) => a.order - b.order); - - // 전체 배열 교체 - cards.delete(0, cards.length); - cards.insert(0, cardsArray); - - this.logger.log(`Cards reordered in cardset ${cardsetId}`); - } catch (error) { - this.logger.error('Error reordering cards:', error); - client.emit('error', { message: 'Failed to reorder cards' }); - } - } - -} \ No newline at end of file diff --git a/src/websocket/websocket.module.ts b/src/websocket/websocket.module.ts deleted file mode 100644 index d36ca66..0000000 --- a/src/websocket/websocket.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CollaborationGateway } from './websocket.gateway'; -import { AuthModule } from '../auth/auth.module'; - -@Module({ - imports: [AuthModule], - providers: [CollaborationGateway], - exports: [CollaborationGateway], -}) -export class WebSocketModule {} \ No newline at end of file diff --git a/src/websocket/yjs-document.service.ts b/src/websocket/yjs-document.service.ts deleted file mode 100644 index e490913..0000000 --- a/src/websocket/yjs-document.service.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import * as Y from 'yjs'; -import { YjsDocument } from './entities/yjs-document.entity'; - -@Injectable() -export class YjsDocumentService { - private readonly logger = new Logger(YjsDocumentService.name); - private documentCache = new Map(); - - constructor( - @InjectRepository(YjsDocument) - private yjsDocumentRepository: Repository, - ) {} - - async getOrCreateDocument(cardsetId: number): Promise { - // 캐시에서 먼저 확인 - if (this.documentCache.has(cardsetId)) { - return this.documentCache.get(cardsetId)!; - } - - // DB에서 문서 로드 시도 - let yjsDoc = await this.yjsDocumentRepository.findOne({ - where: { cardsetId }, - }); - - let doc: Y.Doc; - - if (yjsDoc) { - // DB에서 문서 복원 - doc = new Y.Doc(); - Y.applyUpdate(doc, yjsDoc.documentData); - this.logger.log(`Loaded Yjs document for cardset ${cardsetId} from DB`); - } else { - // 새 문서 생성 - doc = new Y.Doc(); - await this.saveDocument(cardsetId, doc); - this.logger.log(`Created new Yjs document for cardset ${cardsetId}`); - } - - // 변경사항 감지하여 DB에 자동 저장 - doc.on('update', async (update: Uint8Array) => { - await this.saveDocument(cardsetId, doc); - }); - - // 캐시에 저장 - this.documentCache.set(cardsetId, doc); - return doc; - } - - async saveDocument(cardsetId: number, doc: Y.Doc): Promise { - try { - const state = Y.encodeStateAsUpdate(doc); - - const existingDoc = await this.yjsDocumentRepository.findOne({ - where: { cardsetId }, - }); - - if (existingDoc) { - await this.yjsDocumentRepository.update(existingDoc.id, { - documentData: Buffer.from(state), - version: existingDoc.version + 1, - }); - } else { - const yjsDoc = this.yjsDocumentRepository.create({ - cardsetId, - documentData: Buffer.from(state), - version: 1, - }); - await this.yjsDocumentRepository.save(yjsDoc); - } - } catch (error) { - this.logger.error(`Failed to save Yjs document for cardset ${cardsetId}:`, error); - } - } - - async deleteDocument(cardsetId: number): Promise { - await this.yjsDocumentRepository.delete({ cardsetId }); - this.documentCache.delete(cardsetId); - this.logger.log(`Deleted Yjs document for cardset ${cardsetId}`); - } - - // DB에서 카드 데이터를 Yjs 문서로 동기화 - async syncCardsFromDB(cardsetId: number, cards: any[]): Promise { - const doc = await this.getOrCreateDocument(cardsetId); - const cardsArray = doc.getArray('cards'); - - // 기존 카드 모두 삭제 - cardsArray.delete(0, cardsArray.length); - - // DB 카드들을 Yjs에 추가 - const yjsCards = cards.map(card => ({ - id: card.id, - title: card.title, - content: card.content, - order: card.order, - cardsetId: card.cardsetId, - createdAt: card.createdAt?.toISOString(), - updatedAt: card.updatedAt?.toISOString(), - })); - - cardsArray.insert(0, yjsCards); - this.logger.log(`Synced ${cards.length} cards from DB to Yjs for cardset ${cardsetId}`); - } -} From 08bde17ee9d8c0d76125b1992f5728e4c983af7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Fri, 6 Mar 2026 00:16:06 +0900 Subject: [PATCH 04/11] =?UTF-8?q?Feat:=20=EC=B9=B4=EB=93=9C=EC=85=8B=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=8B=9C=20=EB=A7=A4=EB=8B=88=EC=A0=80=20id?= =?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 | 13 ++++++- src/cardset/cardset.module.ts | 3 ++ .../repository/cardset-manager.repository.ts | 10 ++++++ .../infrastructure/http/cardset.controller.ts | 4 +-- .../cardset-manager.repository.impl.ts | 36 +++++++++++++++++++ .../mapper/cardset-manager.mapper.ts | 16 +++++++++ 6 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 src/cardset/domain/repository/cardset-manager.repository.ts create mode 100644 src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts create mode 100644 src/cardset/infrastructure/persistence/mapper/cardset-manager.mapper.ts diff --git a/src/cardset/application/cardset.use-case.ts b/src/cardset/application/cardset.use-case.ts index 8fed80c..7603e5f 100644 --- a/src/cardset/application/cardset.use-case.ts +++ b/src/cardset/application/cardset.use-case.ts @@ -1,9 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Cardset } from '../domain/model/cardset'; +import { CardsetManager } from '../domain/model/cardset-manager'; import { CARDSET_REPOSITORY } from '../domain/repository/cardset.repository'; import type { ICardsetRepository } from '../domain/repository/cardset.repository'; import { CARD_REPOSITORY } from '../domain/repository/card.repository'; import type { ICardRepository } from '../domain/repository/card.repository'; +import { CARDSET_MANAGER_REPOSITORY } from '../domain/repository/cardset-manager.repository'; +import type { ICardsetManagerRepository } from '../domain/repository/cardset-manager.repository'; import { CardsetCardDomainService } from '../domain/service/cardset-card.domain-service'; import { CreateCardsetDto } from './dto/create-cardset.dto'; import { UpdateCardsetDto } from './dto/update-cardset.dto'; @@ -15,10 +18,12 @@ export class CardsetUseCase { private readonly cardsetRepository: ICardsetRepository, @Inject(CARD_REPOSITORY) private readonly cardRepository: ICardRepository, + @Inject(CARDSET_MANAGER_REPOSITORY) + private readonly cardsetManagerRepository: ICardsetManagerRepository, private readonly cardsetCardDomainService: CardsetCardDomainService, ) {} - async create(dto: CreateCardsetDto): Promise { + async create(userId: number, dto: CreateCardsetDto): Promise { const cardset = Cardset.create(dto); const savedCardset = await this.cardsetRepository.save(cardset); @@ -32,6 +37,12 @@ export class CardsetUseCase { await this.cardRepository.save(card); } + const cardsetManager = CardsetManager.create({ + userId, + cardSetId: savedCardset.id, + }); + await this.cardsetManagerRepository.save(cardsetManager); + return savedCardset; } diff --git a/src/cardset/cardset.module.ts b/src/cardset/cardset.module.ts index 0b53eaa..be68dae 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -7,9 +7,11 @@ import { CardsetManagerOrmEntity } from './infrastructure/persistence/orm/cardse import { CardsetRepositoryImpl } from './infrastructure/persistence/cardset.repository.impl'; import { CardRepositoryImpl } from './infrastructure/persistence/card.repository.impl'; +import { CardsetManagerRepositoryImpl } from './infrastructure/persistence/cardset-manager.repository.impl'; import { CARDSET_REPOSITORY } from './domain/repository/cardset.repository'; import { CARD_REPOSITORY } from './domain/repository/card.repository'; +import { CARDSET_MANAGER_REPOSITORY } from './domain/repository/cardset-manager.repository'; import { CardsetCardDomainService } from './domain/service/cardset-card.domain-service'; @@ -31,6 +33,7 @@ import { CardController } from './infrastructure/http/card.controller'; providers: [ { provide: CARDSET_REPOSITORY, useClass: CardsetRepositoryImpl }, { provide: CARD_REPOSITORY, useClass: CardRepositoryImpl }, + { provide: CARDSET_MANAGER_REPOSITORY, useClass: CardsetManagerRepositoryImpl }, CardsetCardDomainService, CardsetUseCase, CardUseCase, diff --git a/src/cardset/domain/repository/cardset-manager.repository.ts b/src/cardset/domain/repository/cardset-manager.repository.ts new file mode 100644 index 0000000..bb2d8d8 --- /dev/null +++ b/src/cardset/domain/repository/cardset-manager.repository.ts @@ -0,0 +1,10 @@ +import { CardsetManager } from '../model/cardset-manager'; + +export const CARDSET_MANAGER_REPOSITORY = Symbol('CARDSET_MANAGER_REPOSITORY'); + +export interface ICardsetManagerRepository { + save(cardsetManager: CardsetManager): Promise; + findByUserIdAndCardSetId(userId: number, cardSetId: number): Promise; + findAllByCardSetId(cardSetId: number): Promise; + delete(id: number): Promise; +} diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts index 62c1878..2906160 100644 --- a/src/cardset/infrastructure/http/cardset.controller.ts +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -19,10 +19,10 @@ export class CardsetController { @Post() async create( - @Headers('X-USER-ID') _userId: string, + @Headers('X-USER-ID') userId: string, @Body() dto: CreateCardsetDto, ): Promise { - return this.cardsetUseCase.create(dto); + return this.cardsetUseCase.create(parseInt(userId), dto); } @Get() diff --git a/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts new file mode 100644 index 0000000..8d81428 --- /dev/null +++ b/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ICardsetManagerRepository } from '../../domain/repository/cardset-manager.repository'; +import { CardsetManager } from '../../domain/model/cardset-manager'; +import { CardsetManagerOrmEntity } from './orm/cardset-manager.orm-entity'; +import { CardsetManagerMapper } from './mapper/cardset-manager.mapper'; + +@Injectable() +export class CardsetManagerRepositoryImpl implements ICardsetManagerRepository { + constructor( + @InjectRepository(CardsetManagerOrmEntity) + private readonly ormRepository: Repository, + ) {} + + async save(cardsetManager: CardsetManager): Promise { + const ormData = CardsetManagerMapper.toOrm(cardsetManager); + const created = this.ormRepository.create(ormData); + const saved = await this.ormRepository.save(created); + return CardsetManagerMapper.toDomain(saved); + } + + async findByUserIdAndCardSetId(userId: number, cardSetId: number): Promise { + const orm = await this.ormRepository.findOne({ where: { userId, cardSetId } }); + return orm ? CardsetManagerMapper.toDomain(orm) : null; + } + + async findAllByCardSetId(cardSetId: number): Promise { + const orms = await this.ormRepository.find({ where: { cardSetId } }); + return orms.map(CardsetManagerMapper.toDomain); + } + + async delete(id: number): Promise { + await this.ormRepository.delete(id); + } +} diff --git a/src/cardset/infrastructure/persistence/mapper/cardset-manager.mapper.ts b/src/cardset/infrastructure/persistence/mapper/cardset-manager.mapper.ts new file mode 100644 index 0000000..ea2b7e2 --- /dev/null +++ b/src/cardset/infrastructure/persistence/mapper/cardset-manager.mapper.ts @@ -0,0 +1,16 @@ +import { CardsetManager } from '../../../domain/model/cardset-manager'; +import { CardsetManagerOrmEntity } from '../orm/cardset-manager.orm-entity'; + +export class CardsetManagerMapper { + static toDomain(orm: CardsetManagerOrmEntity): CardsetManager { + return new CardsetManager(orm.id, orm.userId, orm.cardSetId); + } + + static toOrm(domain: CardsetManager): Partial { + return { + id: domain.id || undefined, + userId: domain.userId, + cardSetId: domain.cardSetId, + }; + } +} From 7b5eff04bc694e013a92f00536d1eb5847ce50fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Mon, 9 Mar 2026 23:20:37 +0900 Subject: [PATCH 05/11] =?UTF-8?q?Feat:=20grpc=EB=82=B4=EC=9A=A9=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 --- nest-cli.json | 8 +- package-lock.json | 196 ++++++++++++++++++ package.json | 3 + src/cardset/cardset.module.ts | 3 +- src/cardset/domain/model/card.ts | 14 +- src/cardset/domain/model/cardset.ts | 14 +- .../grpc/cardset.grpc-controller.ts | 29 +++ src/main.ts | 12 ++ src/proto/cardset.proto | 22 ++ src/shared/domain/base.entity.ts | 7 + src/shared/grpc/grpc-client.module.ts | 28 +++ src/shared/grpc/grpc.types.ts | 25 +++ 12 files changed, 349 insertions(+), 12 deletions(-) create mode 100644 src/cardset/infrastructure/grpc/cardset.grpc-controller.ts create mode 100644 src/proto/cardset.proto create mode 100644 src/shared/domain/base.entity.ts create mode 100644 src/shared/grpc/grpc-client.module.ts create mode 100644 src/shared/grpc/grpc.types.ts diff --git a/nest-cli.json b/nest-cli.json index f9aa683..342a8a4 100644 --- a/nest-cli.json +++ b/nest-cli.json @@ -3,6 +3,12 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "assets": [ + { + "include": "proto/**/*", + "watchAssets": true + } + ] } } diff --git a/package-lock.json b/package-lock.json index 74c2947..c8f5c6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,13 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/mapped-types": "^2.1.0", + "@nestjs/microservices": "^11.1.16", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.6", "@nestjs/swagger": "^11.2.0", @@ -964,6 +967,37 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2058,6 +2092,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -2436,6 +2480,64 @@ } } }, + "node_modules/@nestjs/microservices": { + "version": "11.1.16", + "resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.1.16.tgz", + "integrity": "sha512-eG/ArIq0UJyR3i/GTYuApA4OZylhuLGacVaVT9mMxQgT7ZTpp5CZgOwLNdcUUdOS6qypK3waG1m2AC54xzdf0Q==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "amqp-connection-manager": "*", + "amqplib": "*", + "cache-manager": "*", + "ioredis": "*", + "kafkajs": "*", + "mqtt": "*", + "nats": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + }, + "amqp-connection-manager": { + "optional": true + }, + "amqplib": { + "optional": true + }, + "cache-manager": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "kafkajs": { + "optional": true + }, + "mqtt": { + "optional": true + }, + "nats": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.6", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.6.tgz", @@ -2690,6 +2792,70 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -7843,6 +8009,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -8875,6 +9047,30 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/package.json b/package.json index d5e40d6..bceb713 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,13 @@ "test:e2e": "jest --passWithNoTests --config ./test/jest-e2e.json" }, "dependencies": { + "@grpc/grpc-js": "^1.14.3", + "@grpc/proto-loader": "^0.8.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/mapped-types": "^2.1.0", + "@nestjs/microservices": "^11.1.16", "@nestjs/platform-express": "^11.0.1", "@nestjs/platform-socket.io": "^11.1.6", "@nestjs/swagger": "^11.2.0", diff --git a/src/cardset/cardset.module.ts b/src/cardset/cardset.module.ts index be68dae..270e2db 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -20,6 +20,7 @@ import { CardUseCase } from './application/card.use-case'; import { CardsetController } from './infrastructure/http/cardset.controller'; import { CardController } from './infrastructure/http/card.controller'; +import { CardsetGrpcController } from './infrastructure/grpc/cardset.grpc-controller'; @Module({ imports: [ @@ -29,7 +30,7 @@ import { CardController } from './infrastructure/http/card.controller'; CardsetManagerOrmEntity, ]), ], - controllers: [CardsetController, CardController], + controllers: [CardsetController, CardController, CardsetGrpcController], providers: [ { provide: CARDSET_REPOSITORY, useClass: CardsetRepositoryImpl }, { provide: CARD_REPOSITORY, useClass: CardRepositoryImpl }, diff --git a/src/cardset/domain/model/card.ts b/src/cardset/domain/model/card.ts index a220f07..fb8c41a 100644 --- a/src/cardset/domain/model/card.ts +++ b/src/cardset/domain/model/card.ts @@ -1,13 +1,17 @@ -export class Card { +import { BaseDomainEntity } from '../../../shared/domain/base.entity'; + +export class Card extends BaseDomainEntity { constructor( public readonly id: number, public content: string, public order: number, public readonly cardsetId: number, - public readonly createdAt: Date, - public readonly updatedAt: Date, - public readonly deletedAt?: Date, - ) {} + createdAt: Date, + updatedAt: Date, + deletedAt?: Date, + ) { + super(createdAt, updatedAt, deletedAt); + } static create(props: { content?: string; diff --git a/src/cardset/domain/model/cardset.ts b/src/cardset/domain/model/cardset.ts index 1f42390..b6a8fbc 100644 --- a/src/cardset/domain/model/cardset.ts +++ b/src/cardset/domain/model/cardset.ts @@ -1,4 +1,6 @@ -export class Cardset { +import { BaseDomainEntity } from '../../../shared/domain/base.entity'; + +export class Cardset extends BaseDomainEntity { constructor( public readonly id: number, public name: string, @@ -8,10 +10,12 @@ export class Cardset { public hashtag: string | null, public imageUrl: string, public cardCount: number, - public readonly createdAt: Date, - public readonly updatedAt: Date, - public readonly deletedAt?: Date, - ) {} + createdAt: Date, + updatedAt: Date, + deletedAt?: Date, + ) { + super(createdAt, updatedAt, deletedAt); + } static create(props: { name: string; diff --git a/src/cardset/infrastructure/grpc/cardset.grpc-controller.ts b/src/cardset/infrastructure/grpc/cardset.grpc-controller.ts new file mode 100644 index 0000000..9046e75 --- /dev/null +++ b/src/cardset/infrastructure/grpc/cardset.grpc-controller.ts @@ -0,0 +1,29 @@ +import { Controller } from '@nestjs/common'; +import { GrpcMethod } from '@nestjs/microservices'; +import { CardsetUseCase } from '../../application/cardset.use-case'; +import type { GetCardsetRequest, CardsetGrpcResponse } from '../../../shared/grpc/grpc.types'; + +@Controller() +export class CardsetGrpcController { + constructor(private readonly cardsetUseCase: CardsetUseCase) {} + + @GrpcMethod('CardsetService', 'GetCardset') + async getCardset(data: GetCardsetRequest): Promise { + const cardset = await this.cardsetUseCase.findOne(data.id); + + if (!cardset) { + throw new Error(`Cardset with id ${data.id} not found`); + } + + return { + id: cardset.id, + name: cardset.name, + groupId: cardset.groupId, + publicVisible: cardset.publicVisible, + category: cardset.category, + hashtag: cardset.hashtag ?? '', + imageUrl: cardset.imageUrl, + cardCount: cardset.cardCount, + }; + } +} diff --git a/src/main.ts b/src/main.ts index 6722a94..bdb7744 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,10 +3,21 @@ import { AppModule } from './app.module'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { IoAdapter } from '@nestjs/platform-socket.io'; import { NestExpressApplication } from '@nestjs/platform-express'; +import { MicroserviceOptions, Transport } from '@nestjs/microservices'; +import { join } from 'path'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.connectMicroservice({ + transport: Transport.GRPC, + options: { + package: 'cardset', + protoPath: join(__dirname, 'proto/cardset.proto'), + url: `0.0.0.0:${process.env.GRPC_PORT ?? 5000}`, + }, + }); + // 정적 파일 서빙 설정 제거 (YJS 제거로 불필요) // Socket.IO 어댑터 설정 @@ -32,6 +43,7 @@ async function bootstrap() { // SwaggerModule.setup('api-docs', app, document); // } + await app.startAllMicroservices(); await app.listen(process.env.PORT ?? 3000); } void bootstrap(); diff --git a/src/proto/cardset.proto b/src/proto/cardset.proto new file mode 100644 index 0000000..e36b510 --- /dev/null +++ b/src/proto/cardset.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package cardset; + +service CardsetService { + rpc GetCardset (GetCardsetRequest) returns (CardsetResponse); +} + +message GetCardsetRequest { + int32 id = 1; +} + +message CardsetResponse { + int32 id = 1; + string name = 2; + int32 group_id = 3; + bool public_visible = 4; + string category = 5; + string hashtag = 6; + string image_url = 7; + int32 card_count = 8; +} diff --git a/src/shared/domain/base.entity.ts b/src/shared/domain/base.entity.ts new file mode 100644 index 0000000..cdb5b4b --- /dev/null +++ b/src/shared/domain/base.entity.ts @@ -0,0 +1,7 @@ +export abstract class BaseDomainEntity { + constructor( + public readonly createdAt: Date, + public readonly updatedAt: Date, + public readonly deletedAt?: Date, + ) {} +} diff --git a/src/shared/grpc/grpc-client.module.ts b/src/shared/grpc/grpc-client.module.ts new file mode 100644 index 0000000..33cbaaa --- /dev/null +++ b/src/shared/grpc/grpc-client.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { join } from 'path'; + +/** + * 외부 gRPC 서비스를 호출하는 클라이언트 모듈 + * + * 사용 예시: + * @Inject('CARDSET_GRPC_CLIENT') private client: ClientGrpc + * this.cardsetService = this.client.getService('CardsetService') + */ +@Module({ + imports: [ + ClientsModule.register([ + { + name: 'CARDSET_GRPC_CLIENT', + transport: Transport.GRPC, + options: { + package: 'cardset', + protoPath: join(__dirname, '../proto/cardset.proto'), + url: process.env.CARDSET_GRPC_URL ?? 'localhost:5000', + }, + }, + ]), + ], + exports: [ClientsModule], +}) +export class GrpcClientModule {} diff --git a/src/shared/grpc/grpc.types.ts b/src/shared/grpc/grpc.types.ts new file mode 100644 index 0000000..1b6a6fb --- /dev/null +++ b/src/shared/grpc/grpc.types.ts @@ -0,0 +1,25 @@ +import { Observable } from 'rxjs'; + +// ── cardset.proto 메시지 타입 ───────────────────────────────────── + +export interface GetCardsetRequest { + id: number; +} + +export interface CardsetGrpcResponse { + id: number; + name: string; + groupId: number; + publicVisible: boolean; + category: string; + hashtag: string; + imageUrl: string; + cardCount: number; +} + +// ── 서비스 클라이언트 인터페이스 ──────────────────────────────────── +// 다른 서비스에서 이 서버를 호출할 때 사용하는 클라이언트 타입 + +export interface CardsetServiceClient { + getCardset(data: GetCardsetRequest): Observable; +} From 71fb083e9d095473626888ddf4c84167fd3dba52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Mon, 9 Mar 2026 23:34:50 +0900 Subject: [PATCH 06/11] =?UTF-8?q?Feat:=20grpc=20=EC=84=A4=EC=A0=95=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/proto/cardset.proto | 4 +-- src/proto/group.proto | 15 +++++++++ src/proto/image.proto | 44 +++++++++++++++++++++++++++ src/proto/user.proto | 27 ++++++++++++++++ src/shared/grpc/grpc-client.module.ts | 33 +++++++++++++------- src/shared/grpc/grpc.types.ts | 4 +-- 6 files changed, 112 insertions(+), 15 deletions(-) create mode 100644 src/proto/group.proto create mode 100644 src/proto/image.proto create mode 100644 src/proto/user.proto diff --git a/src/proto/cardset.proto b/src/proto/cardset.proto index e36b510..ecf86ba 100644 --- a/src/proto/cardset.proto +++ b/src/proto/cardset.proto @@ -14,9 +14,9 @@ message CardsetResponse { int32 id = 1; string name = 2; int32 group_id = 3; - bool public_visible = 4; + string visibility = 4; string category = 5; string hashtag = 6; - string image_url = 7; + int64 image_ref_id = 7; int32 card_count = 8; } diff --git a/src/proto/group.proto b/src/proto/group.proto new file mode 100644 index 0000000..a6539b4 --- /dev/null +++ b/src/proto/group.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package group.v1; + +service GroupCommandService { + rpc GetGroupName (GetGroupNameRequest) returns (GetGroupNameResponse); +} + +message GetGroupNameRequest { + int64 group_id = 1; +} + +message GetGroupNameResponse { + string group_name = 1; +} diff --git a/src/proto/image.proto b/src/proto/image.proto new file mode 100644 index 0000000..77174e2 --- /dev/null +++ b/src/proto/image.proto @@ -0,0 +1,44 @@ +syntax = "proto3"; + +package image.v1; + +service ImageCommandService { + rpc GetUrlByReference (GetUrlByReferenceRequest) returns (GetUrlByReferenceResponse); + rpc ActivateImage (ActivateImageRequest) returns (ActivateImageResponse); + rpc ChangeImage (ChangeImageRequest) returns (ChangeImageResponse); +} + +enum ReferenceType { + REFERENCE_TYPE_UNSPECIFIED = 0; + USER = 1; + GROUP = 2; + CARD_SET = 3; +} + +message GetUrlByReferenceRequest { + ReferenceType reference_type = 1; + int64 reference_id = 2; +} + +message GetUrlByReferenceResponse { + string image_url = 1; +} + +message ActivateImageRequest { + int64 image_ref_id = 1; + ReferenceType reference_type = 2; + int64 reference_id = 3; +} + +message ActivateImageResponse {} + +message ChangeImageRequest { + ReferenceType reference_type = 1; + int64 reference_id = 2; + int64 image_ref_id = 3; +} + +message ChangeImageResponse { + int64 image_ref_id = 1; + string url = 2; +} diff --git a/src/proto/user.proto b/src/proto/user.proto new file mode 100644 index 0000000..e09bb79 --- /dev/null +++ b/src/proto/user.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package user_query; + +service UserQueryService { + rpc GetUser (GetUserRequest) returns (GetUserResponse); + rpc GetUsers (GetUsersRequest) returns (GetUsersResponse); +} + +message GetUserRequest { + int64 user_id = 1; +} + +message GetUserResponse { + int64 id = 1; + string email = 2; + string nickname = 3; + string profile_image_url = 4; +} + +message GetUsersRequest { + repeated int64 user_ids = 1; +} + +message GetUsersResponse { + repeated GetUserResponse users = 1; +} diff --git a/src/shared/grpc/grpc-client.module.ts b/src/shared/grpc/grpc-client.module.ts index 33cbaaa..064d88e 100644 --- a/src/shared/grpc/grpc-client.module.ts +++ b/src/shared/grpc/grpc-client.module.ts @@ -2,23 +2,34 @@ import { Module } from '@nestjs/common'; import { ClientsModule, Transport } from '@nestjs/microservices'; import { join } from 'path'; -/** - * 외부 gRPC 서비스를 호출하는 클라이언트 모듈 - * - * 사용 예시: - * @Inject('CARDSET_GRPC_CLIENT') private client: ClientGrpc - * this.cardsetService = this.client.getService('CardsetService') - */ @Module({ imports: [ ClientsModule.register([ { - name: 'CARDSET_GRPC_CLIENT', + name: 'GROUP_GRPC_CLIENT', transport: Transport.GRPC, options: { - package: 'cardset', - protoPath: join(__dirname, '../proto/cardset.proto'), - url: process.env.CARDSET_GRPC_URL ?? 'localhost:5000', + package: 'group.v1', + protoPath: join(__dirname, '../proto/group.proto'), + url: process.env.GRPC_GROUP_URL ?? 'localhost:9094', + }, + }, + { + name: 'IMAGE_GRPC_CLIENT', + transport: Transport.GRPC, + options: { + package: 'image.v1', + protoPath: join(__dirname, '../proto/image.proto'), + url: process.env.GRPC_IMAGE_URL ?? 'localhost:9092', + }, + }, + { + name: 'USER_GRPC_CLIENT', + transport: Transport.GRPC, + options: { + package: 'user_query', + protoPath: join(__dirname, '../proto/user.proto'), + url: process.env.GRPC_USER_URL ?? 'localhost:9091', }, }, ]), diff --git a/src/shared/grpc/grpc.types.ts b/src/shared/grpc/grpc.types.ts index 1b6a6fb..d26e3db 100644 --- a/src/shared/grpc/grpc.types.ts +++ b/src/shared/grpc/grpc.types.ts @@ -10,10 +10,10 @@ export interface CardsetGrpcResponse { id: number; name: string; groupId: number; - publicVisible: boolean; + visibility: string; category: string; hashtag: string; - imageUrl: string; + imageRefId: number; cardCount: number; } From ec56770d25d45a705528d0f1ea5e048c2723715f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Mon, 9 Mar 2026 23:35:02 +0900 Subject: [PATCH 07/11] =?UTF-8?q?Fix:=20=EC=97=94=ED=8B=B0=ED=8B=B0=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 --- .../application/dto/create-cardset.dto.ts | 6 ++-- .../application/dto/update-cardset.dto.ts | 5 +-- src/cardset/domain/model/cardset.ts | 36 ++++++++++--------- src/cardset/domain/model/visibility.ts | 4 +++ .../grpc/cardset.grpc-controller.ts | 4 +-- .../persistence/mapper/cardset.mapper.ts | 8 ++--- .../persistence/orm/cardset.orm-entity.ts | 13 +++---- 7 files changed, 43 insertions(+), 33 deletions(-) create mode 100644 src/cardset/domain/model/visibility.ts diff --git a/src/cardset/application/dto/create-cardset.dto.ts b/src/cardset/application/dto/create-cardset.dto.ts index a521d9b..ff91510 100644 --- a/src/cardset/application/dto/create-cardset.dto.ts +++ b/src/cardset/application/dto/create-cardset.dto.ts @@ -1,9 +1,11 @@ +import { Visibility } from '../../domain/model/visibility'; + export class CreateCardsetDto { name!: string; groupId!: number; - publicVisible!: boolean; + visibility!: Visibility; category!: string; hashtag?: string | null; - imageUrl!: string; + imageRefId!: number; cardCount?: number; } diff --git a/src/cardset/application/dto/update-cardset.dto.ts b/src/cardset/application/dto/update-cardset.dto.ts index 0c1343b..b4a2ad6 100644 --- a/src/cardset/application/dto/update-cardset.dto.ts +++ b/src/cardset/application/dto/update-cardset.dto.ts @@ -1,7 +1,8 @@ +import { Visibility } from '../../domain/model/visibility'; + export class UpdateCardsetDto { name?: string; - publicVisible?: boolean; + visibility?: Visibility; category?: string; hashtag?: string | null; - imageUrl?: string; } diff --git a/src/cardset/domain/model/cardset.ts b/src/cardset/domain/model/cardset.ts index b6a8fbc..5578ddc 100644 --- a/src/cardset/domain/model/cardset.ts +++ b/src/cardset/domain/model/cardset.ts @@ -1,14 +1,15 @@ import { BaseDomainEntity } from '../../../shared/domain/base.entity'; +import { Visibility } from './visibility'; export class Cardset extends BaseDomainEntity { constructor( public readonly id: number, public name: string, public groupId: number, - public publicVisible: boolean, + public visibility: Visibility, public category: string, public hashtag: string | null, - public imageUrl: string, + public readonly imageRefId: number, public cardCount: number, createdAt: Date, updatedAt: Date, @@ -20,41 +21,42 @@ export class Cardset extends BaseDomainEntity { static create(props: { name: string; groupId: number; - publicVisible: boolean; + visibility: Visibility; category: string; hashtag?: string | null; - imageUrl: string; + imageRefId: number; cardCount?: number; }): Cardset { return new Cardset( 0, props.name, props.groupId, - props.publicVisible, + props.visibility, props.category, props.hashtag ?? null, - props.imageUrl, + props.imageRefId, props.cardCount ?? 10, new Date(), new Date(), ); } - updateInfo(props: Partial<{ - name: string; - publicVisible: boolean; - category: string; - hashtag: string | null; - imageUrl: string; - }>): Cardset { + updateInfo( + props: Partial<{ + name: string; + visibility: Visibility; + category: string; + hashtag: string | null; + }>, + ): Cardset { return new Cardset( this.id, props.name ?? this.name, this.groupId, - props.publicVisible ?? this.publicVisible, + props.visibility ?? this.visibility, props.category ?? this.category, props.hashtag !== undefined ? props.hashtag : this.hashtag, - props.imageUrl ?? this.imageUrl, + this.imageRefId, this.cardCount, this.createdAt, new Date(), @@ -67,10 +69,10 @@ export class Cardset extends BaseDomainEntity { this.id, this.name, this.groupId, - this.publicVisible, + this.visibility, this.category, this.hashtag, - this.imageUrl, + this.imageRefId, newCount, this.createdAt, new Date(), diff --git a/src/cardset/domain/model/visibility.ts b/src/cardset/domain/model/visibility.ts new file mode 100644 index 0000000..862b3e0 --- /dev/null +++ b/src/cardset/domain/model/visibility.ts @@ -0,0 +1,4 @@ +export enum Visibility { + PUBLIC = 'PUBLIC', + PRIVATE = 'PRIVATE', +} diff --git a/src/cardset/infrastructure/grpc/cardset.grpc-controller.ts b/src/cardset/infrastructure/grpc/cardset.grpc-controller.ts index 9046e75..0854098 100644 --- a/src/cardset/infrastructure/grpc/cardset.grpc-controller.ts +++ b/src/cardset/infrastructure/grpc/cardset.grpc-controller.ts @@ -19,10 +19,10 @@ export class CardsetGrpcController { id: cardset.id, name: cardset.name, groupId: cardset.groupId, - publicVisible: cardset.publicVisible, + visibility: cardset.visibility, category: cardset.category, hashtag: cardset.hashtag ?? '', - imageUrl: cardset.imageUrl, + imageRefId: cardset.imageRefId, cardCount: cardset.cardCount, }; } diff --git a/src/cardset/infrastructure/persistence/mapper/cardset.mapper.ts b/src/cardset/infrastructure/persistence/mapper/cardset.mapper.ts index 076860e..a730fa1 100644 --- a/src/cardset/infrastructure/persistence/mapper/cardset.mapper.ts +++ b/src/cardset/infrastructure/persistence/mapper/cardset.mapper.ts @@ -7,10 +7,10 @@ export class CardsetMapper { orm.id, orm.name, orm.groupId, - orm.publicVisible, + orm.visibility, orm.category, orm.hashtag ?? null, - orm.imageUrl, + Number(orm.imageRefId), orm.cardCount, orm.createdAt, orm.updatedAt, @@ -23,10 +23,10 @@ export class CardsetMapper { id: domain.id || undefined, name: domain.name, groupId: domain.groupId, - publicVisible: domain.publicVisible, + visibility: domain.visibility, category: domain.category, hashtag: domain.hashtag, - imageUrl: domain.imageUrl, + imageRefId: domain.imageRefId, cardCount: domain.cardCount, }; } diff --git a/src/cardset/infrastructure/persistence/orm/cardset.orm-entity.ts b/src/cardset/infrastructure/persistence/orm/cardset.orm-entity.ts index 1d6eee5..d539252 100644 --- a/src/cardset/infrastructure/persistence/orm/cardset.orm-entity.ts +++ b/src/cardset/infrastructure/persistence/orm/cardset.orm-entity.ts @@ -1,5 +1,6 @@ import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; import { BaseOrmEntity } from '../../../../shared/infrastructure/persistence/base.orm-entity'; +import { Visibility } from '../../../domain/model/visibility'; @Entity('card_sets') export class CardsetOrmEntity extends BaseOrmEntity { @@ -13,12 +14,12 @@ export class CardsetOrmEntity extends BaseOrmEntity { groupId!: number; @Column({ - name: 'is_public', - type: 'boolean', + type: 'enum', + enum: Visibility, nullable: false, - default: false, + default: Visibility.PRIVATE, }) - publicVisible!: boolean; + visibility!: Visibility; @Column({ type: 'varchar', length: 50, nullable: false }) category!: string; @@ -26,8 +27,8 @@ export class CardsetOrmEntity extends BaseOrmEntity { @Column({ type: 'varchar', nullable: true }) hashtag?: string | null; - @Column({ name: 'image_url', type: 'varchar', nullable: false }) - imageUrl!: string; + @Column({ name: 'image_ref_id', type: 'bigint', nullable: false }) + imageRefId!: number; @Column({ name: 'card_count', type: 'int', default: 10 }) cardCount!: number; From 56b0814b3967ace0e4091b15f5b86845e90e5010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Mon, 9 Mar 2026 23:39:26 +0900 Subject: [PATCH 08/11] =?UTF-8?q?Feat:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=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/dto/create-card.dto.ts | 7 +++++++ src/cardset/application/dto/create-cardset.dto.ts | 14 ++++++++++++++ src/cardset/application/dto/reorder-cards.dto.ts | 13 ++++++++++++- src/cardset/application/dto/update-card.dto.ts | 5 +++++ src/cardset/application/dto/update-cardset.dto.ts | 8 ++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/cardset/application/dto/create-card.dto.ts b/src/cardset/application/dto/create-card.dto.ts index cef343c..a0dad5f 100644 --- a/src/cardset/application/dto/create-card.dto.ts +++ b/src/cardset/application/dto/create-card.dto.ts @@ -1,5 +1,12 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + export class CreateCardDto { + @ApiPropertyOptional({ example: '안녕하세요' }) content?: string; + + @ApiProperty({ example: 1 }) order!: number; + + @ApiProperty({ example: 1 }) cardsetId!: number; } diff --git a/src/cardset/application/dto/create-cardset.dto.ts b/src/cardset/application/dto/create-cardset.dto.ts index ff91510..0bf69cf 100644 --- a/src/cardset/application/dto/create-cardset.dto.ts +++ b/src/cardset/application/dto/create-cardset.dto.ts @@ -1,11 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Visibility } from '../../domain/model/visibility'; export class CreateCardsetDto { + @ApiProperty({ example: '영어 단어장' }) name!: string; + + @ApiProperty({ example: 1 }) groupId!: number; + + @ApiProperty({ enum: Visibility, example: Visibility.PRIVATE }) visibility!: Visibility; + + @ApiProperty({ example: '언어' }) category!: string; + + @ApiPropertyOptional({ example: '#영어#단어' }) hashtag?: string | null; + + @ApiProperty({ example: 1001 }) imageRefId!: number; + + @ApiPropertyOptional({ example: 10 }) cardCount?: number; } diff --git a/src/cardset/application/dto/reorder-cards.dto.ts b/src/cardset/application/dto/reorder-cards.dto.ts index f639bea..b279e1c 100644 --- a/src/cardset/application/dto/reorder-cards.dto.ts +++ b/src/cardset/application/dto/reorder-cards.dto.ts @@ -1,3 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +class CardOrderItem { + @ApiProperty({ example: 1 }) + cardId!: number; + + @ApiProperty({ example: 2 }) + order!: number; +} + export class ReorderCardsDto { - cardOrders!: { cardId: number; order: number }[]; + @ApiProperty({ type: [CardOrderItem] }) + cardOrders!: CardOrderItem[]; } diff --git a/src/cardset/application/dto/update-card.dto.ts b/src/cardset/application/dto/update-card.dto.ts index ec086d1..ae6c280 100644 --- a/src/cardset/application/dto/update-card.dto.ts +++ b/src/cardset/application/dto/update-card.dto.ts @@ -1,4 +1,9 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; + export class UpdateCardDto { + @ApiPropertyOptional({ example: '수정된 내용' }) content?: string; + + @ApiPropertyOptional({ example: 2 }) order?: number; } diff --git a/src/cardset/application/dto/update-cardset.dto.ts b/src/cardset/application/dto/update-cardset.dto.ts index b4a2ad6..acce720 100644 --- a/src/cardset/application/dto/update-cardset.dto.ts +++ b/src/cardset/application/dto/update-cardset.dto.ts @@ -1,8 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Visibility } from '../../domain/model/visibility'; export class UpdateCardsetDto { + @ApiPropertyOptional({ example: '수정된 단어장' }) name?: string; + + @ApiPropertyOptional({ enum: Visibility, example: Visibility.PUBLIC }) visibility?: Visibility; + + @ApiPropertyOptional({ example: '수학' }) category?: string; + + @ApiPropertyOptional({ example: '#수학,#공식' }) hashtag?: string | null; } From 21f88d65b72b4ad0f93123cb1014479131c84394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Tue, 10 Mar 2026 21:12:26 +0900 Subject: [PATCH 09/11] =?UTF-8?q?Feat:=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EB=82=B4=EC=9A=A9=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/application/card.use-case.ts | 10 ++- src/cardset/application/cardset.use-case.ts | 86 ++++++++++--------- .../application/dto/update-cardset.dto.ts | 2 +- .../domain/repository/card.repository.ts | 7 +- .../repository/cardset-manager.repository.ts | 3 +- .../domain/repository/cardset.repository.ts | 3 +- .../persistence/card.repository.impl.ts | 19 ++-- .../cardset-manager.repository.impl.ts | 9 +- .../persistence/cardset.repository.impl.ts | 9 +- src/proto/group.proto | 10 +++ 10 files changed, 93 insertions(+), 65 deletions(-) diff --git a/src/cardset/application/card.use-case.ts b/src/cardset/application/card.use-case.ts index 34dd78e..82f157a 100644 --- a/src/cardset/application/card.use-case.ts +++ b/src/cardset/application/card.use-case.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; import { Card } from '../domain/model/card'; import { CARD_REPOSITORY } from '../domain/repository/card.repository'; import type { ICardRepository } from '../domain/repository/card.repository'; @@ -10,6 +11,7 @@ export class CardUseCase { constructor( @Inject(CARD_REPOSITORY) private readonly cardRepository: ICardRepository, + private readonly dataSource: DataSource, ) {} async create(dto: CreateCardDto): Promise { @@ -34,8 +36,10 @@ export class CardUseCase { } async reorderCards(cardOrders: { cardId: number; order: number }[]): Promise { - for (const { cardId, order } of cardOrders) { - await this.cardRepository.updateOrder(cardId, order); - } + await this.dataSource.transaction(async (manager) => { + for (const { cardId, order } of cardOrders) { + await this.cardRepository.updateOrder(cardId, order, manager); + } + }); } } diff --git a/src/cardset/application/cardset.use-case.ts b/src/cardset/application/cardset.use-case.ts index 7603e5f..f64b3fb 100644 --- a/src/cardset/application/cardset.use-case.ts +++ b/src/cardset/application/cardset.use-case.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; import { Cardset } from '../domain/model/cardset'; import { CardsetManager } from '../domain/model/cardset-manager'; import { CARDSET_REPOSITORY } from '../domain/repository/cardset.repository'; @@ -21,29 +22,32 @@ export class CardsetUseCase { @Inject(CARDSET_MANAGER_REPOSITORY) private readonly cardsetManagerRepository: ICardsetManagerRepository, private readonly cardsetCardDomainService: CardsetCardDomainService, + private readonly dataSource: DataSource, ) {} async create(userId: number, dto: CreateCardsetDto): Promise { - const cardset = Cardset.create(dto); - const savedCardset = await this.cardsetRepository.save(cardset); + 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, - ); - for (const card of cardsToAdd) { - await this.cardRepository.save(card); - } + const cardCount = dto.cardCount ?? 10; + const cardsToAdd = this.cardsetCardDomainService.buildCardsToAdd( + savedCardset.id, + 0, + cardCount, + ); + for (const card of cardsToAdd) { + await this.cardRepository.save(card, manager); + } - const cardsetManager = CardsetManager.create({ - userId, - cardSetId: savedCardset.id, - }); - await this.cardsetManagerRepository.save(cardsetManager); + const cardsetManager = CardsetManager.create({ + userId, + cardSetId: savedCardset.id, + }); + await this.cardsetManagerRepository.save(cardsetManager, manager); - return savedCardset; + return savedCardset; + }); } async findAll(): Promise { @@ -63,32 +67,34 @@ export class CardsetUseCase { } async updateCardCount(id: number, newCardCount: number): Promise { - const cardset = await this.cardsetRepository.findById(id); - if (!cardset) return null; + return this.dataSource.transaction(async (manager) => { + const cardset = await this.cardsetRepository.findById(id); + if (!cardset) return null; - const currentCards = await this.cardRepository.findAllByCardsetId(id); - const currentCount = currentCards.length; + const currentCards = await this.cardRepository.findAllByCardsetId(id); + const currentCount = currentCards.length; - if (newCardCount > currentCount) { - const cardsToAdd = this.cardsetCardDomainService.buildCardsToAdd( - id, - currentCount, - newCardCount, - ); - for (const card of cardsToAdd) { - await this.cardRepository.save(card); + if (newCardCount > currentCount) { + const cardsToAdd = this.cardsetCardDomainService.buildCardsToAdd( + id, + currentCount, + newCardCount, + ); + for (const card of cardsToAdd) { + await this.cardRepository.save(card, manager); + } + } else if (newCardCount < currentCount) { + const cardsToRemove = this.cardsetCardDomainService.selectCardsToRemove( + currentCards, + newCardCount, + ); + for (const card of cardsToRemove) { + await this.cardRepository.delete(card.id, manager); + } } - } else if (newCardCount < currentCount) { - const cardsToRemove = this.cardsetCardDomainService.selectCardsToRemove( - currentCards, - newCardCount, - ); - for (const card of cardsToRemove) { - await this.cardRepository.delete(card.id); - } - } - const updatedCardset = cardset.changeCardCount(newCardCount); - return this.cardsetRepository.update(id, updatedCardset); + const updatedCardset = cardset.changeCardCount(newCardCount); + return this.cardsetRepository.update(id, updatedCardset); + }); } } diff --git a/src/cardset/application/dto/update-cardset.dto.ts b/src/cardset/application/dto/update-cardset.dto.ts index acce720..546a38f 100644 --- a/src/cardset/application/dto/update-cardset.dto.ts +++ b/src/cardset/application/dto/update-cardset.dto.ts @@ -11,6 +11,6 @@ export class UpdateCardsetDto { @ApiPropertyOptional({ example: '수학' }) category?: string; - @ApiPropertyOptional({ example: '#수학,#공식' }) + @ApiPropertyOptional({ example: '#수학 #공식' }) hashtag?: string | null; } diff --git a/src/cardset/domain/repository/card.repository.ts b/src/cardset/domain/repository/card.repository.ts index 02875dd..6d35829 100644 --- a/src/cardset/domain/repository/card.repository.ts +++ b/src/cardset/domain/repository/card.repository.ts @@ -1,3 +1,4 @@ +import { EntityManager } from 'typeorm'; import { Card } from '../model/card'; export const CARD_REPOSITORY = Symbol('CARD_REPOSITORY'); @@ -5,8 +6,8 @@ export const CARD_REPOSITORY = Symbol('CARD_REPOSITORY'); export interface ICardRepository { findById(id: number): Promise; findAllByCardsetId(cardsetId: number): Promise; - save(card: Card): Promise; + save(card: Card, manager?: EntityManager): Promise; update(id: number, card: Partial): Promise; - delete(id: number): Promise; - updateOrder(cardId: number, order: number): Promise; + delete(id: number, manager?: EntityManager): Promise; + updateOrder(cardId: number, order: number, manager?: EntityManager): Promise; } diff --git a/src/cardset/domain/repository/cardset-manager.repository.ts b/src/cardset/domain/repository/cardset-manager.repository.ts index bb2d8d8..5a52919 100644 --- a/src/cardset/domain/repository/cardset-manager.repository.ts +++ b/src/cardset/domain/repository/cardset-manager.repository.ts @@ -1,9 +1,10 @@ +import { EntityManager } from 'typeorm'; import { CardsetManager } from '../model/cardset-manager'; export const CARDSET_MANAGER_REPOSITORY = Symbol('CARDSET_MANAGER_REPOSITORY'); export interface ICardsetManagerRepository { - save(cardsetManager: CardsetManager): Promise; + save(cardsetManager: CardsetManager, manager?: EntityManager): Promise; findByUserIdAndCardSetId(userId: number, cardSetId: number): Promise; findAllByCardSetId(cardSetId: 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 c6f1a97..9277c7a 100644 --- a/src/cardset/domain/repository/cardset.repository.ts +++ b/src/cardset/domain/repository/cardset.repository.ts @@ -1,3 +1,4 @@ +import { EntityManager } from 'typeorm'; import { Cardset } from '../model/cardset'; export const CARDSET_REPOSITORY = Symbol('CARDSET_REPOSITORY'); @@ -5,7 +6,7 @@ export const CARDSET_REPOSITORY = Symbol('CARDSET_REPOSITORY'); export interface ICardsetRepository { findAll(): Promise; findById(id: number): Promise; - save(cardset: Cardset): Promise; + save(cardset: Cardset, manager?: EntityManager): Promise; update(id: number, cardset: Partial): Promise; delete(id: number): Promise; } diff --git a/src/cardset/infrastructure/persistence/card.repository.impl.ts b/src/cardset/infrastructure/persistence/card.repository.impl.ts index 747096c..cc3bfd4 100644 --- a/src/cardset/infrastructure/persistence/card.repository.impl.ts +++ b/src/cardset/infrastructure/persistence/card.repository.impl.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; import { ICardRepository } from '../../domain/repository/card.repository'; import { Card } from '../../domain/model/card'; import { CardOrmEntity } from './orm/card.orm-entity'; @@ -26,10 +26,11 @@ export class CardRepositoryImpl implements ICardRepository { return orms.map(CardMapper.toDomain); } - async save(card: Card): Promise { + async save(card: Card, manager?: EntityManager): Promise { + const repo = manager ? manager.getRepository(CardOrmEntity) : this.ormRepository; const ormData = CardMapper.toOrm(card); - const created = this.ormRepository.create(ormData); - const saved = await this.ormRepository.save(created); + const created = repo.create(ormData); + const saved = await repo.save(created); return CardMapper.toDomain(saved); } @@ -38,11 +39,13 @@ export class CardRepositoryImpl implements ICardRepository { return this.findById(id); } - async delete(id: number): Promise { - await this.ormRepository.delete(id); + async delete(id: number, manager?: EntityManager): Promise { + const repo = manager ? manager.getRepository(CardOrmEntity) : this.ormRepository; + await repo.delete(id); } - async updateOrder(cardId: number, order: number): Promise { - await this.ormRepository.update(cardId, { order }); + async updateOrder(cardId: number, order: number, manager?: EntityManager): Promise { + const repo = manager ? manager.getRepository(CardOrmEntity) : this.ormRepository; + await repo.update(cardId, { order }); } } diff --git a/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts index 8d81428..f93a91f 100644 --- a/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts +++ b/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; import { ICardsetManagerRepository } from '../../domain/repository/cardset-manager.repository'; import { CardsetManager } from '../../domain/model/cardset-manager'; import { CardsetManagerOrmEntity } from './orm/cardset-manager.orm-entity'; @@ -13,10 +13,11 @@ export class CardsetManagerRepositoryImpl implements ICardsetManagerRepository { private readonly ormRepository: Repository, ) {} - async save(cardsetManager: CardsetManager): Promise { + async save(cardsetManager: CardsetManager, manager?: EntityManager): Promise { + const repo = manager ? manager.getRepository(CardsetManagerOrmEntity) : this.ormRepository; const ormData = CardsetManagerMapper.toOrm(cardsetManager); - const created = this.ormRepository.create(ormData); - const saved = await this.ormRepository.save(created); + const created = repo.create(ormData); + const saved = await repo.save(created); return CardsetManagerMapper.toDomain(saved); } diff --git a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts index 00a0545..bd4d9e3 100644 --- a/src/cardset/infrastructure/persistence/cardset.repository.impl.ts +++ b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; import { ICardsetRepository } from '../../domain/repository/cardset.repository'; import { Cardset } from '../../domain/model/cardset'; import { CardsetOrmEntity } from './orm/cardset.orm-entity'; @@ -25,10 +25,11 @@ export class CardsetRepositoryImpl implements ICardsetRepository { return orm ? CardsetMapper.toDomain(orm) : null; } - async save(cardset: Cardset): Promise { + async save(cardset: Cardset, manager?: EntityManager): Promise { + const repo = manager ? manager.getRepository(CardsetOrmEntity) : this.ormRepository; const ormData = CardsetMapper.toOrm(cardset); - const created = this.ormRepository.create(ormData); - const saved = await this.ormRepository.save(created); + const created = repo.create(ormData); + const saved = await repo.save(created); return CardsetMapper.toDomain(saved); } diff --git a/src/proto/group.proto b/src/proto/group.proto index a6539b4..329997a 100644 --- a/src/proto/group.proto +++ b/src/proto/group.proto @@ -4,6 +4,7 @@ package group.v1; service GroupCommandService { rpc GetGroupName (GetGroupNameRequest) returns (GetGroupNameResponse); + rpc CheckUserInGroup (CheckUserInGroupRequest) returns (CheckUserInGroupResponse); } message GetGroupNameRequest { @@ -13,3 +14,12 @@ message GetGroupNameRequest { message GetGroupNameResponse { string group_name = 1; } + +message CheckUserInGroupRequest { + int64 group_id = 1; + int64 user_id = 2; +} + +message CheckUserInGroupResponse { + bool exists = 1; +} From d21ee29573e6ab1911792e325c8829622185358a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Tue, 10 Mar 2026 21:41:13 +0900 Subject: [PATCH 10/11] =?UTF-8?q?Fix:=20response=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cardset/application/card.use-case.ts | 8 +-- src/cardset/application/cardset.use-case.ts | 21 ++++-- .../create-card.request.ts} | 2 +- .../create-cardset.request.ts} | 4 +- .../reorder-cards.request.ts} | 2 +- .../update-card.request.ts} | 2 +- .../update-cardset.request.ts} | 4 +- .../dto/response/card-create.response.ts | 12 ++++ .../application/dto/response/card.response.ts | 33 ++++++++++ .../dto/response/cardset-create.response.ts | 12 ++++ .../dto/response/cardset.response.ts | 50 ++++++++++++++ src/cardset/cardset.module.ts | 4 ++ .../infrastructure/grpc/group-grpc.client.ts | 30 +++++++++ .../infrastructure/http/card.controller.ts | 52 ++++++++------- .../infrastructure/http/cardset.controller.ts | 65 ++++++++++++------- src/shared/grpc/grpc-client.module.ts | 6 +- 16 files changed, 242 insertions(+), 65 deletions(-) rename src/cardset/application/dto/{create-card.dto.ts => request/create-card.request.ts} (88%) rename src/cardset/application/dto/{create-cardset.dto.ts => request/create-cardset.request.ts} (84%) rename src/cardset/application/dto/{reorder-cards.dto.ts => request/reorder-cards.request.ts} (87%) rename src/cardset/application/dto/{update-card.dto.ts => request/update-card.request.ts} (85%) rename src/cardset/application/dto/{update-cardset.dto.ts => request/update-cardset.request.ts} (79%) create mode 100644 src/cardset/application/dto/response/card-create.response.ts create mode 100644 src/cardset/application/dto/response/card.response.ts create mode 100644 src/cardset/application/dto/response/cardset-create.response.ts create mode 100644 src/cardset/application/dto/response/cardset.response.ts create mode 100644 src/cardset/infrastructure/grpc/group-grpc.client.ts diff --git a/src/cardset/application/card.use-case.ts b/src/cardset/application/card.use-case.ts index 82f157a..34b5cb7 100644 --- a/src/cardset/application/card.use-case.ts +++ b/src/cardset/application/card.use-case.ts @@ -3,8 +3,8 @@ import { DataSource } from 'typeorm'; import { Card } from '../domain/model/card'; import { CARD_REPOSITORY } from '../domain/repository/card.repository'; import type { ICardRepository } from '../domain/repository/card.repository'; -import { CreateCardDto } from './dto/create-card.dto'; -import { UpdateCardDto } from './dto/update-card.dto'; +import { CreateCardRequest } from './dto/request/create-card.request'; +import { UpdateCardRequest } from './dto/request/update-card.request'; @Injectable() export class CardUseCase { @@ -14,7 +14,7 @@ export class CardUseCase { private readonly dataSource: DataSource, ) {} - async create(dto: CreateCardDto): Promise { + async create(dto: CreateCardRequest): Promise { const card = Card.create(dto); return this.cardRepository.save(card); } @@ -27,7 +27,7 @@ export class CardUseCase { return this.cardRepository.findById(id); } - async update(id: number, dto: UpdateCardDto): Promise { + async update(id: number, dto: UpdateCardRequest): Promise { return this.cardRepository.update(id, dto); } diff --git a/src/cardset/application/cardset.use-case.ts b/src/cardset/application/cardset.use-case.ts index f64b3fb..758aea6 100644 --- a/src/cardset/application/cardset.use-case.ts +++ b/src/cardset/application/cardset.use-case.ts @@ -9,8 +9,9 @@ import type { ICardRepository } from '../domain/repository/card.repository'; import { CARDSET_MANAGER_REPOSITORY } from '../domain/repository/cardset-manager.repository'; import type { ICardsetManagerRepository } from '../domain/repository/cardset-manager.repository'; import { CardsetCardDomainService } from '../domain/service/cardset-card.domain-service'; -import { CreateCardsetDto } from './dto/create-cardset.dto'; -import { UpdateCardsetDto } from './dto/update-cardset.dto'; +import { GroupGrpcClient } from '../infrastructure/grpc/group-grpc.client'; +import { CreateCardsetRequest } from './dto/request/create-cardset.request'; +import { UpdateCardsetRequest } from './dto/request/update-cardset.request'; @Injectable() export class CardsetUseCase { @@ -22,10 +23,13 @@ export class CardsetUseCase { @Inject(CARDSET_MANAGER_REPOSITORY) private readonly cardsetManagerRepository: ICardsetManagerRepository, private readonly cardsetCardDomainService: CardsetCardDomainService, + private readonly groupGrpcClient: GroupGrpcClient, private readonly dataSource: DataSource, ) {} - async create(userId: number, dto: CreateCardsetDto): Promise { + async create(userId: number, dto: CreateCardsetRequest): Promise { + await this.groupGrpcClient.checkUserInGroup(dto.groupId, userId); + return this.dataSource.transaction(async (manager) => { const cardset = Cardset.create(dto); const savedCardset = await this.cardsetRepository.save(cardset, manager); @@ -58,7 +62,16 @@ export class CardsetUseCase { return this.cardsetRepository.findById(id); } - async update(id: number, dto: UpdateCardsetDto): Promise { + async update( + id: number, + userId: number, + dto: UpdateCardsetRequest, + ): Promise { + const cardset = await this.cardsetRepository.findById(id); + if (!cardset) return null; + + await this.groupGrpcClient.checkUserInGroup(cardset.groupId, userId); + return this.cardsetRepository.update(id, dto); } diff --git a/src/cardset/application/dto/create-card.dto.ts b/src/cardset/application/dto/request/create-card.request.ts similarity index 88% rename from src/cardset/application/dto/create-card.dto.ts rename to src/cardset/application/dto/request/create-card.request.ts index a0dad5f..4dff85b 100644 --- a/src/cardset/application/dto/create-card.dto.ts +++ b/src/cardset/application/dto/request/create-card.request.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -export class CreateCardDto { +export class CreateCardRequest { @ApiPropertyOptional({ example: '안녕하세요' }) content?: string; diff --git a/src/cardset/application/dto/create-cardset.dto.ts b/src/cardset/application/dto/request/create-cardset.request.ts similarity index 84% rename from src/cardset/application/dto/create-cardset.dto.ts rename to src/cardset/application/dto/request/create-cardset.request.ts index 0bf69cf..7ba0c43 100644 --- a/src/cardset/application/dto/create-cardset.dto.ts +++ b/src/cardset/application/dto/request/create-cardset.request.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Visibility } from '../../domain/model/visibility'; +import { Visibility } from '../../../domain/model/visibility'; -export class CreateCardsetDto { +export class CreateCardsetRequest { @ApiProperty({ example: '영어 단어장' }) name!: string; diff --git a/src/cardset/application/dto/reorder-cards.dto.ts b/src/cardset/application/dto/request/reorder-cards.request.ts similarity index 87% rename from src/cardset/application/dto/reorder-cards.dto.ts rename to src/cardset/application/dto/request/reorder-cards.request.ts index b279e1c..ccf6da3 100644 --- a/src/cardset/application/dto/reorder-cards.dto.ts +++ b/src/cardset/application/dto/request/reorder-cards.request.ts @@ -8,7 +8,7 @@ class CardOrderItem { order!: number; } -export class ReorderCardsDto { +export class ReorderCardsRequest { @ApiProperty({ type: [CardOrderItem] }) cardOrders!: CardOrderItem[]; } diff --git a/src/cardset/application/dto/update-card.dto.ts b/src/cardset/application/dto/request/update-card.request.ts similarity index 85% rename from src/cardset/application/dto/update-card.dto.ts rename to src/cardset/application/dto/request/update-card.request.ts index ae6c280..e73bad3 100644 --- a/src/cardset/application/dto/update-card.dto.ts +++ b/src/cardset/application/dto/request/update-card.request.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -export class UpdateCardDto { +export class UpdateCardRequest { @ApiPropertyOptional({ example: '수정된 내용' }) content?: string; diff --git a/src/cardset/application/dto/update-cardset.dto.ts b/src/cardset/application/dto/request/update-cardset.request.ts similarity index 79% rename from src/cardset/application/dto/update-cardset.dto.ts rename to src/cardset/application/dto/request/update-cardset.request.ts index 546a38f..89937f4 100644 --- a/src/cardset/application/dto/update-cardset.dto.ts +++ b/src/cardset/application/dto/request/update-cardset.request.ts @@ -1,7 +1,7 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { Visibility } from '../../domain/model/visibility'; +import { Visibility } from '../../../domain/model/visibility'; -export class UpdateCardsetDto { +export class UpdateCardsetRequest { @ApiPropertyOptional({ example: '수정된 단어장' }) name?: string; diff --git a/src/cardset/application/dto/response/card-create.response.ts b/src/cardset/application/dto/response/card-create.response.ts new file mode 100644 index 0000000..a5aa505 --- /dev/null +++ b/src/cardset/application/dto/response/card-create.response.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CardCreateResponse { + @ApiProperty({ example: 1 }) + cardId!: number; + + static from(id: number): CardCreateResponse { + const res = new CardCreateResponse(); + res.cardId = id; + return res; + } +} diff --git a/src/cardset/application/dto/response/card.response.ts b/src/cardset/application/dto/response/card.response.ts new file mode 100644 index 0000000..8a73533 --- /dev/null +++ b/src/cardset/application/dto/response/card.response.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Card } from '../../../domain/model/card'; + +export class CardResponse { + @ApiProperty({ example: 1 }) + id!: number; + + @ApiPropertyOptional({ example: '안녕하세요' }) + content!: string; + + @ApiProperty({ example: 1 }) + order!: number; + + @ApiProperty({ example: 1 }) + cardsetId!: number; + + @ApiProperty() + createdAt!: Date; + + @ApiProperty() + updatedAt!: Date; + + static from(card: Card): CardResponse { + const res = new CardResponse(); + res.id = card.id; + res.content = card.content; + res.order = card.order; + res.cardsetId = card.cardsetId; + res.createdAt = card.createdAt; + res.updatedAt = card.updatedAt; + return res; + } +} diff --git a/src/cardset/application/dto/response/cardset-create.response.ts b/src/cardset/application/dto/response/cardset-create.response.ts new file mode 100644 index 0000000..ea05688 --- /dev/null +++ b/src/cardset/application/dto/response/cardset-create.response.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CardsetCreateResponse { + @ApiProperty({ example: 1 }) + cardsetId!: number; + + static from(id: number): CardsetCreateResponse { + const res = new CardsetCreateResponse(); + res.cardsetId = id; + return res; + } +} diff --git a/src/cardset/application/dto/response/cardset.response.ts b/src/cardset/application/dto/response/cardset.response.ts new file mode 100644 index 0000000..6a53807 --- /dev/null +++ b/src/cardset/application/dto/response/cardset.response.ts @@ -0,0 +1,50 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Visibility } from '../../../domain/model/visibility'; +import { Cardset } from '../../../domain/model/cardset'; + +export class CardsetResponse { + @ApiProperty({ example: 1 }) + id!: number; + + @ApiProperty({ example: '영어 단어장' }) + name!: string; + + @ApiProperty({ example: 1 }) + groupId!: number; + + @ApiProperty({ enum: Visibility, example: Visibility.PRIVATE }) + visibility!: Visibility; + + @ApiProperty({ example: '언어' }) + category!: string; + + @ApiPropertyOptional({ example: '#영어#단어' }) + hashtag!: string | null; + + @ApiProperty({ example: 1001 }) + imageRefId!: number; + + @ApiProperty({ example: 10 }) + cardCount!: number; + + @ApiProperty() + createdAt!: Date; + + @ApiProperty() + updatedAt!: Date; + + static from(cardset: Cardset): CardsetResponse { + const res = new CardsetResponse(); + res.id = cardset.id; + res.name = cardset.name; + res.groupId = cardset.groupId; + res.visibility = cardset.visibility; + res.category = cardset.category; + res.hashtag = cardset.hashtag; + res.imageRefId = cardset.imageRefId; + res.cardCount = cardset.cardCount; + res.createdAt = cardset.createdAt; + res.updatedAt = cardset.updatedAt; + return res; + } +} diff --git a/src/cardset/cardset.module.ts b/src/cardset/cardset.module.ts index 270e2db..85c603a 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -21,6 +21,8 @@ import { CardUseCase } from './application/card.use-case'; import { CardsetController } from './infrastructure/http/cardset.controller'; import { CardController } from './infrastructure/http/card.controller'; import { CardsetGrpcController } from './infrastructure/grpc/cardset.grpc-controller'; +import { GroupGrpcClient } from './infrastructure/grpc/group-grpc.client'; +import { GrpcClientModule } from '../shared/grpc/grpc-client.module'; @Module({ imports: [ @@ -29,6 +31,7 @@ import { CardsetGrpcController } from './infrastructure/grpc/cardset.grpc-contro CardOrmEntity, CardsetManagerOrmEntity, ]), + GrpcClientModule, ], controllers: [CardsetController, CardController, CardsetGrpcController], providers: [ @@ -36,6 +39,7 @@ import { CardsetGrpcController } from './infrastructure/grpc/cardset.grpc-contro { provide: CARD_REPOSITORY, useClass: CardRepositoryImpl }, { provide: CARDSET_MANAGER_REPOSITORY, useClass: CardsetManagerRepositoryImpl }, CardsetCardDomainService, + GroupGrpcClient, CardsetUseCase, CardUseCase, ], diff --git a/src/cardset/infrastructure/grpc/group-grpc.client.ts b/src/cardset/infrastructure/grpc/group-grpc.client.ts new file mode 100644 index 0000000..fba9b40 --- /dev/null +++ b/src/cardset/infrastructure/grpc/group-grpc.client.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable, OnModuleInit, ForbiddenException } from '@nestjs/common'; +import type { ClientGrpc } from '@nestjs/microservices'; +import { Observable, firstValueFrom } from 'rxjs'; + +interface GroupCommandService { + checkUserInGroup(data: { + groupId: number; + userId: number; + }): Observable<{ exists: boolean }>; +} + +@Injectable() +export class GroupGrpcClient implements OnModuleInit { + private groupService: GroupCommandService; + + constructor(@Inject('GROUP_GRPC_CLIENT') private readonly client: ClientGrpc) {} + + onModuleInit() { + this.groupService = this.client.getService('GroupCommandService'); + } + + async checkUserInGroup(groupId: number, userId: number): Promise { + const result = await firstValueFrom( + this.groupService.checkUserInGroup({ groupId, userId }), + ); + if (!result.exists) { + throw new ForbiddenException('해당 그룹에 속한 유저가 아닙니다.'); + } + } +} diff --git a/src/cardset/infrastructure/http/card.controller.ts b/src/cardset/infrastructure/http/card.controller.ts index 4296ea5..3283e15 100644 --- a/src/cardset/infrastructure/http/card.controller.ts +++ b/src/cardset/infrastructure/http/card.controller.ts @@ -9,9 +9,11 @@ import { Headers, } from '@nestjs/common'; import { CardUseCase } from '../../application/card.use-case'; -import { CreateCardDto } from '../../application/dto/create-card.dto'; -import { UpdateCardDto } from '../../application/dto/update-card.dto'; -import { Card } from '../../domain/model/card'; +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'; @Controller('cards') export class CardController { @@ -20,49 +22,55 @@ export class CardController { @Post() async create( @Headers('X-USER-ID') _userId: string, - @Body() dto: CreateCardDto, - ): Promise { - return this.cardUseCase.create(dto); + @Body() dto: CreateCardRequest, + ): Promise { + const card = await this.cardUseCase.create(dto); + return CardCreateResponse.from(card.id); } @Get('cardset/:cardsetId') async findByCardsetId( @Headers('X-USER-ID') _userId: string, @Param('cardsetId') cardsetId: string, - ): Promise { - return this.cardUseCase.findAllByCardsetId(parseInt(cardsetId)); + ): Promise { + const cards = await this.cardUseCase.findAllByCardsetId( + parseInt(cardsetId), + ); + return cards.map((c) => CardResponse.from(c)); } - @Get(':id') + @Get(':cardId') async findOne( @Headers('X-USER-ID') _userId: string, - @Param('id') id: string, - ): Promise { - return this.cardUseCase.findOne(parseInt(id)); + @Param('cardId') cardId: string, + ): Promise { + const card = await this.cardUseCase.findOne(parseInt(cardId)); + return card ? CardResponse.from(card) : null; } @Put('reorder') async reorderCards( @Headers('X-USER-ID') _userId: string, - @Body() cardOrders: { cardId: number; order: number }[], + @Body() dto: ReorderCardsRequest, ): Promise { - return this.cardUseCase.reorderCards(cardOrders); + return this.cardUseCase.reorderCards(dto.cardOrders); } - @Put(':id') + @Put(':cardId') async update( @Headers('X-USER-ID') _userId: string, - @Param('id') id: string, - @Body() dto: UpdateCardDto, - ): Promise { - return this.cardUseCase.update(parseInt(id), dto); + @Param('cardId') cardId: string, + @Body() dto: UpdateCardRequest, + ): Promise { + const card = await this.cardUseCase.update(parseInt(cardId), dto); + return card ? CardResponse.from(card) : null; } - @Delete(':id') + @Delete(':cardId') async remove( @Headers('X-USER-ID') _userId: string, - @Param('id') id: string, + @Param('cardId') cardId: string, ): Promise { - return this.cardUseCase.remove(parseInt(id)); + return this.cardUseCase.remove(parseInt(cardId)); } } diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts index 2906160..500a48b 100644 --- a/src/cardset/infrastructure/http/cardset.controller.ts +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -9,9 +9,10 @@ import { Headers, } from '@nestjs/common'; import { CardsetUseCase } from '../../application/cardset.use-case'; -import { CreateCardsetDto } from '../../application/dto/create-cardset.dto'; -import { UpdateCardsetDto } from '../../application/dto/update-cardset.dto'; -import { Cardset } from '../../domain/model/cardset'; +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'; @Controller('cardsets') export class CardsetController { @@ -20,47 +21,61 @@ export class CardsetController { @Post() async create( @Headers('X-USER-ID') userId: string, - @Body() dto: CreateCardsetDto, - ): Promise { - return this.cardsetUseCase.create(parseInt(userId), dto); + @Body() dto: CreateCardsetRequest, + ): Promise { + const cardset = await this.cardsetUseCase.create(parseInt(userId), dto); + return CardsetCreateResponse.from(cardset.id); } @Get() - async findAll(@Headers('X-USER-ID') _userId: string): Promise { - return this.cardsetUseCase.findAll(); + async findAll( + @Headers('X-USER-ID') _userId: string, + ): Promise { + const cardsets = await this.cardsetUseCase.findAll(); + return cardsets.map((c) => CardsetResponse.from(c)); } - @Get(':id') + @Get(':cardsetId') async findOne( @Headers('X-USER-ID') _userId: string, - @Param('id') id: string, - ): Promise { - return this.cardsetUseCase.findOne(parseInt(id)); + @Param('cardsetId') cardsetId: string, + ): Promise { + const cardset = await this.cardsetUseCase.findOne(parseInt(cardsetId)); + return cardset ? CardsetResponse.from(cardset) : null; } - @Put(':id') + @Put(':cardsetId') async update( - @Headers('X-USER-ID') _userId: string, - @Param('id') id: string, - @Body() dto: UpdateCardsetDto, - ): Promise { - return this.cardsetUseCase.update(parseInt(id), dto); + @Headers('X-USER-ID') userId: string, + @Param('cardsetId') cardsetId: string, + @Body() dto: UpdateCardsetRequest, + ): Promise { + const cardset = await this.cardsetUseCase.update( + parseInt(cardsetId), + parseInt(userId), + dto, + ); + return cardset ? CardsetResponse.from(cardset) : null; } - @Delete(':id') + @Delete(':cardsetId') async remove( @Headers('X-USER-ID') _userId: string, - @Param('id') id: string, + @Param('cardsetId') cardsetId: string, ): Promise { - return this.cardsetUseCase.remove(parseInt(id)); + return this.cardsetUseCase.remove(parseInt(cardsetId)); } - @Put(':id/card-count') + @Put(':cardsetId/card-count') async updateCardCount( @Headers('X-USER-ID') _userId: string, - @Param('id') id: string, + @Param('cardsetId') cardsetId: string, @Body() body: { cardCount: number }, - ): Promise { - return this.cardsetUseCase.updateCardCount(parseInt(id), body.cardCount); + ): Promise { + const cardset = await this.cardsetUseCase.updateCardCount( + parseInt(cardsetId), + body.cardCount, + ); + return cardset ? CardsetResponse.from(cardset) : null; } } diff --git a/src/shared/grpc/grpc-client.module.ts b/src/shared/grpc/grpc-client.module.ts index 064d88e..c9cd65f 100644 --- a/src/shared/grpc/grpc-client.module.ts +++ b/src/shared/grpc/grpc-client.module.ts @@ -10,7 +10,7 @@ import { join } from 'path'; transport: Transport.GRPC, options: { package: 'group.v1', - protoPath: join(__dirname, '../proto/group.proto'), + protoPath: join(__dirname, '../../proto/group.proto'), url: process.env.GRPC_GROUP_URL ?? 'localhost:9094', }, }, @@ -19,7 +19,7 @@ import { join } from 'path'; transport: Transport.GRPC, options: { package: 'image.v1', - protoPath: join(__dirname, '../proto/image.proto'), + protoPath: join(__dirname, '../../proto/image.proto'), url: process.env.GRPC_IMAGE_URL ?? 'localhost:9092', }, }, @@ -28,7 +28,7 @@ import { join } from 'path'; transport: Transport.GRPC, options: { package: 'user_query', - protoPath: join(__dirname, '../proto/user.proto'), + protoPath: join(__dirname, '../../proto/user.proto'), url: process.env.GRPC_USER_URL ?? 'localhost:9091', }, }, From e5e56b56b02f06645cd13d2ff22443d4fe4e43df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=84=9D=EB=B2=94?= Date: Tue, 10 Mar 2026 21:47:03 +0900 Subject: [PATCH 11/11] =?UTF-8?q?Feat:=20=EB=A7=A4=EB=8B=88=EC=A0=80=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=20=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 | 36 ++++++++++++++++--- .../infrastructure/http/cardset.controller.ts | 7 ++-- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/cardset/application/cardset.use-case.ts b/src/cardset/application/cardset.use-case.ts index 758aea6..7e5a189 100644 --- a/src/cardset/application/cardset.use-case.ts +++ b/src/cardset/application/cardset.use-case.ts @@ -1,4 +1,9 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { + ForbiddenException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { DataSource } from 'typeorm'; import { Cardset } from '../domain/model/cardset'; import { CardsetManager } from '../domain/model/cardset-manager'; @@ -27,6 +32,20 @@ export class CardsetUseCase { private readonly dataSource: DataSource, ) {} + private async checkIsManager( + cardSetId: number, + userId: number, + ): Promise { + const manager = + await this.cardsetManagerRepository.findByUserIdAndCardSetId( + userId, + cardSetId, + ); + if (!manager) { + throw new ForbiddenException('카드셋 매니저만 접근할 수 있습니다.'); + } + } + async create(userId: number, dto: CreateCardsetRequest): Promise { await this.groupGrpcClient.checkUserInGroup(dto.groupId, userId); @@ -68,18 +87,25 @@ export class CardsetUseCase { dto: UpdateCardsetRequest, ): Promise { const cardset = await this.cardsetRepository.findById(id); - if (!cardset) return null; + if (!cardset) throw new NotFoundException('카드셋을 찾을 수 없습니다.'); - await this.groupGrpcClient.checkUserInGroup(cardset.groupId, userId); + await this.checkIsManager(id, userId); return this.cardsetRepository.update(id, dto); } - async remove(id: number): Promise { + async remove(id: number, userId: number): Promise { + await this.checkIsManager(id, userId); return this.cardsetRepository.delete(id); } - async updateCardCount(id: number, newCardCount: number): Promise { + async updateCardCount( + id: number, + userId: number, + newCardCount: number, + ): Promise { + await this.checkIsManager(id, userId); + return this.dataSource.transaction(async (manager) => { const cardset = await this.cardsetRepository.findById(id); if (!cardset) return null; diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts index 500a48b..f1c441a 100644 --- a/src/cardset/infrastructure/http/cardset.controller.ts +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -60,20 +60,21 @@ export class CardsetController { @Delete(':cardsetId') async remove( - @Headers('X-USER-ID') _userId: string, + @Headers('X-USER-ID') userId: string, @Param('cardsetId') cardsetId: string, ): Promise { - return this.cardsetUseCase.remove(parseInt(cardsetId)); + return this.cardsetUseCase.remove(parseInt(cardsetId), parseInt(userId)); } @Put(':cardsetId/card-count') async updateCardCount( - @Headers('X-USER-ID') _userId: string, + @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 cardset ? CardsetResponse.from(cardset) : null;