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/app.module.ts b/src/app.module.ts index 17d7c3e..ebf6f67 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,15 +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 { 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 { 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', @@ -20,15 +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, CardSetManager, Card], - synchronize: false, + entities: [ + CardsetOrmEntity, + CardOrmEntity, + CardsetManagerOrmEntity, + YjsDocumentOrmEntity, + ], + synchronize: true, }), + AuthModule, CardsetModule, - CardsetManagerModule, - 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 0932aa8..0000000 --- a/src/card/card.controller.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Controller } from '@nestjs/common'; -import { CardService } from './card.service'; - -@Controller('card') -export class CardController { - constructor(private readonly cardService: CardService) {} -} diff --git a/src/card/card.module.ts b/src/card/card.module.ts deleted file mode 100644 index 579e0cf..0000000 --- a/src/card/card.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CardService } from './card.service'; -import { CardController } from './card.controller'; - -@Module({ - controllers: [CardController], - providers: [CardService], -}) -export class CardModule {} 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 5f7ecb9..0000000 --- a/src/card/card.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class CardService {} diff --git a/src/card/entities/card.entity.ts b/src/card/entities/card.entity.ts deleted file mode 100644 index 4eb1b2e..0000000 --- a/src/card/entities/card.entity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Cardset } from '../../cardset/entities/cardset.entity'; -import { - Column, - Entity, - ManyToOne, - PrimaryGeneratedColumn, - JoinColumn, -} from 'typeorm'; - -@Entity('card') -export class Card { - @PrimaryGeneratedColumn({ type: 'int' }) - id!: number; - - @Column({ name: 'card_set_id', type: 'int', nullable: false }) - cardSetId!: number; - - @ManyToOne(() => Cardset, { createForeignKeyConstraints: false }) - @JoinColumn({ name: 'card_set_id' }) - cardset?: Cardset; - - @Column({ type: 'text', nullable: false }) - content!: string; -} 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..34b5cb7 --- /dev/null +++ b/src/cardset/application/card.use-case.ts @@ -0,0 +1,45 @@ +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'; +import { CreateCardRequest } from './dto/request/create-card.request'; +import { UpdateCardRequest } from './dto/request/update-card.request'; + +@Injectable() +export class CardUseCase { + constructor( + @Inject(CARD_REPOSITORY) + private readonly cardRepository: ICardRepository, + private readonly dataSource: DataSource, + ) {} + + async create(dto: CreateCardRequest): 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: UpdateCardRequest): 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 { + 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 new file mode 100644 index 0000000..7e5a189 --- /dev/null +++ b/src/cardset/application/cardset.use-case.ts @@ -0,0 +1,139 @@ +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'; +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 { 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 { + constructor( + @Inject(CARDSET_REPOSITORY) + private readonly cardsetRepository: ICardsetRepository, + @Inject(CARD_REPOSITORY) + private readonly cardRepository: ICardRepository, + @Inject(CARDSET_MANAGER_REPOSITORY) + private readonly cardsetManagerRepository: ICardsetManagerRepository, + private readonly cardsetCardDomainService: CardsetCardDomainService, + private readonly groupGrpcClient: GroupGrpcClient, + 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); + + 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, manager); + } + + const cardsetManager = CardsetManager.create({ + userId, + cardSetId: savedCardset.id, + }); + await this.cardsetManagerRepository.save(cardsetManager, manager); + + return savedCardset; + }); + } + + async findAll(): Promise { + return this.cardsetRepository.findAll(); + } + + async findOne(id: number): Promise { + return this.cardsetRepository.findById(id); + } + + async update( + id: number, + userId: number, + dto: UpdateCardsetRequest, + ): Promise { + const cardset = await this.cardsetRepository.findById(id); + if (!cardset) throw new NotFoundException('카드셋을 찾을 수 없습니다.'); + + await this.checkIsManager(id, userId); + + return this.cardsetRepository.update(id, dto); + } + + async remove(id: number, userId: number): Promise { + await this.checkIsManager(id, userId); + return this.cardsetRepository.delete(id); + } + + 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; + + 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, manager); + } + } else if (newCardCount < currentCount) { + const cardsToRemove = this.cardsetCardDomainService.selectCardsToRemove( + currentCards, + newCardCount, + ); + for (const card of cardsToRemove) { + await this.cardRepository.delete(card.id, manager); + } + } + + const updatedCardset = cardset.changeCardCount(newCardCount); + return this.cardsetRepository.update(id, updatedCardset); + }); + } +} diff --git a/src/cardset/application/dto/request/create-card.request.ts b/src/cardset/application/dto/request/create-card.request.ts new file mode 100644 index 0000000..4dff85b --- /dev/null +++ b/src/cardset/application/dto/request/create-card.request.ts @@ -0,0 +1,12 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class CreateCardRequest { + @ApiPropertyOptional({ example: '안녕하세요' }) + content?: string; + + @ApiProperty({ example: 1 }) + order!: number; + + @ApiProperty({ example: 1 }) + cardsetId!: number; +} diff --git a/src/cardset/application/dto/request/create-cardset.request.ts b/src/cardset/application/dto/request/create-cardset.request.ts new file mode 100644 index 0000000..7ba0c43 --- /dev/null +++ b/src/cardset/application/dto/request/create-cardset.request.ts @@ -0,0 +1,25 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Visibility } from '../../../domain/model/visibility'; + +export class CreateCardsetRequest { + @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/request/reorder-cards.request.ts b/src/cardset/application/dto/request/reorder-cards.request.ts new file mode 100644 index 0000000..ccf6da3 --- /dev/null +++ b/src/cardset/application/dto/request/reorder-cards.request.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; + +class CardOrderItem { + @ApiProperty({ example: 1 }) + cardId!: number; + + @ApiProperty({ example: 2 }) + order!: number; +} + +export class ReorderCardsRequest { + @ApiProperty({ type: [CardOrderItem] }) + cardOrders!: CardOrderItem[]; +} diff --git a/src/cardset/application/dto/request/update-card.request.ts b/src/cardset/application/dto/request/update-card.request.ts new file mode 100644 index 0000000..e73bad3 --- /dev/null +++ b/src/cardset/application/dto/request/update-card.request.ts @@ -0,0 +1,9 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateCardRequest { + @ApiPropertyOptional({ example: '수정된 내용' }) + content?: string; + + @ApiPropertyOptional({ example: 2 }) + order?: number; +} diff --git a/src/cardset/application/dto/request/update-cardset.request.ts b/src/cardset/application/dto/request/update-cardset.request.ts new file mode 100644 index 0000000..89937f4 --- /dev/null +++ b/src/cardset/application/dto/request/update-cardset.request.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Visibility } from '../../../domain/model/visibility'; + +export class UpdateCardsetRequest { + @ApiPropertyOptional({ example: '수정된 단어장' }) + name?: string; + + @ApiPropertyOptional({ enum: Visibility, example: Visibility.PUBLIC }) + visibility?: Visibility; + + @ApiPropertyOptional({ example: '수학' }) + category?: string; + + @ApiPropertyOptional({ example: '#수학 #공식' }) + hashtag?: string | null; +} 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.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 f4fd1c4..0000000 --- a/src/cardset/cardset.controller.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Controller } from '@nestjs/common'; -import { CardsetService } from './cardset.service'; - -@Controller('cardset') -export class CardsetController { - constructor(private readonly cardsetService: CardsetService) {} -} diff --git a/src/cardset/cardset.module.ts b/src/cardset/cardset.module.ts index 2153f26..85c603a 100644 --- a/src/cardset/cardset.module.ts +++ b/src/cardset/cardset.module.ts @@ -1,9 +1,48 @@ import { Module } from '@nestjs/common'; -import { CardsetService } from './cardset.service'; -import { CardsetController } from './cardset.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +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 { 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'; + +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'; +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({ - controllers: [CardsetController], - providers: [CardsetService], + imports: [ + TypeOrmModule.forFeature([ + CardsetOrmEntity, + CardOrmEntity, + CardsetManagerOrmEntity, + ]), + GrpcClientModule, + ], + controllers: [CardsetController, CardController, CardsetGrpcController], + providers: [ + { provide: CARDSET_REPOSITORY, useClass: CardsetRepositoryImpl }, + { provide: CARD_REPOSITORY, useClass: CardRepositoryImpl }, + { provide: CARDSET_MANAGER_REPOSITORY, useClass: CardsetManagerRepositoryImpl }, + CardsetCardDomainService, + GroupGrpcClient, + CardsetUseCase, + CardUseCase, + ], + exports: [CardsetUseCase, CardUseCase], }) 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 927df7f..0000000 --- a/src/cardset/cardset.service.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -@Injectable() -export class CardsetService {} diff --git a/src/cardset/domain/model/card.ts b/src/cardset/domain/model/card.ts new file mode 100644 index 0000000..fb8c41a --- /dev/null +++ b/src/cardset/domain/model/card.ts @@ -0,0 +1,54 @@ +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, + createdAt: Date, + updatedAt: Date, + deletedAt?: Date, + ) { + super(createdAt, updatedAt, deletedAt); + } + + 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..5578ddc --- /dev/null +++ b/src/cardset/domain/model/cardset.ts @@ -0,0 +1,82 @@ +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 visibility: Visibility, + public category: string, + public hashtag: string | null, + public readonly imageRefId: number, + public cardCount: number, + createdAt: Date, + updatedAt: Date, + deletedAt?: Date, + ) { + super(createdAt, updatedAt, deletedAt); + } + + static create(props: { + name: string; + groupId: number; + visibility: Visibility; + category: string; + hashtag?: string | null; + imageRefId: number; + cardCount?: number; + }): Cardset { + return new Cardset( + 0, + props.name, + props.groupId, + props.visibility, + props.category, + props.hashtag ?? null, + props.imageRefId, + props.cardCount ?? 10, + new Date(), + new Date(), + ); + } + + 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.visibility ?? this.visibility, + props.category ?? this.category, + props.hashtag !== undefined ? props.hashtag : this.hashtag, + this.imageRefId, + this.cardCount, + this.createdAt, + new Date(), + this.deletedAt, + ); + } + + changeCardCount(newCount: number): Cardset { + return new Cardset( + this.id, + this.name, + this.groupId, + this.visibility, + this.category, + this.hashtag, + this.imageRefId, + newCount, + this.createdAt, + new Date(), + this.deletedAt, + ); + } +} 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/domain/repository/card.repository.ts b/src/cardset/domain/repository/card.repository.ts new file mode 100644 index 0000000..6d35829 --- /dev/null +++ b/src/cardset/domain/repository/card.repository.ts @@ -0,0 +1,13 @@ +import { EntityManager } from 'typeorm'; +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, manager?: EntityManager): Promise; + update(id: number, card: Partial): 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 new file mode 100644 index 0000000..5a52919 --- /dev/null +++ b/src/cardset/domain/repository/cardset-manager.repository.ts @@ -0,0 +1,11 @@ +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, 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 new file mode 100644 index 0000000..9277c7a --- /dev/null +++ b/src/cardset/domain/repository/cardset.repository.ts @@ -0,0 +1,12 @@ +import { EntityManager } from 'typeorm'; +import { Cardset } from '../model/cardset'; + +export const CARDSET_REPOSITORY = Symbol('CARDSET_REPOSITORY'); + +export interface ICardsetRepository { + findAll(): Promise; + findById(id: number): Promise; + save(cardset: Cardset, manager?: EntityManager): 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/entities/cardset.entity.ts b/src/cardset/entities/cardset.entity.ts deleted file mode 100644 index bc91d83..0000000 --- a/src/cardset/entities/cardset.entity.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity('card_set') -export class Cardset { - @PrimaryGeneratedColumn({ type: 'int' }) - id!: number; - - @Column({ type: 'varchar', length: 50, nullable: false }) - name!: string; - - @Column({ name: 'group_id', type: 'int', nullable: false }) - groupId!: number; - - @Column({ - name: 'is_public', - type: 'boolean', - nullable: false, - default: false, - }) - publicVisible!: boolean; - - @Column({ type: 'varchar', length: 50, nullable: false }) - category!: string; - - @Column({ type: 'varchar', nullable: true }) - hashtag?: string | null; - - @Column({ name: 'image_url', type: 'varchar', nullable: false }) - imageUrl!: 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..0854098 --- /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, + visibility: cardset.visibility, + category: cardset.category, + hashtag: cardset.hashtag ?? '', + imageRefId: cardset.imageRefId, + cardCount: cardset.cardCount, + }; + } +} 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 new file mode 100644 index 0000000..3283e15 --- /dev/null +++ b/src/cardset/infrastructure/http/card.controller.ts @@ -0,0 +1,76 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Headers, +} from '@nestjs/common'; +import { CardUseCase } from '../../application/card.use-case'; +import { CreateCardRequest } from '../../application/dto/request/create-card.request'; +import { UpdateCardRequest } from '../../application/dto/request/update-card.request'; +import { ReorderCardsRequest } from '../../application/dto/request/reorder-cards.request'; +import { CardCreateResponse } from '../../application/dto/response/card-create.response'; +import { CardResponse } from '../../application/dto/response/card.response'; + +@Controller('cards') +export class CardController { + constructor(private readonly cardUseCase: CardUseCase) {} + + @Post() + async create( + @Headers('X-USER-ID') _userId: string, + @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 { + const cards = await this.cardUseCase.findAllByCardsetId( + parseInt(cardsetId), + ); + return cards.map((c) => CardResponse.from(c)); + } + + @Get(':cardId') + async findOne( + @Headers('X-USER-ID') _userId: string, + @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() dto: ReorderCardsRequest, + ): Promise { + return this.cardUseCase.reorderCards(dto.cardOrders); + } + + @Put(':cardId') + async update( + @Headers('X-USER-ID') _userId: string, + @Param('cardId') cardId: string, + @Body() dto: UpdateCardRequest, + ): Promise { + const card = await this.cardUseCase.update(parseInt(cardId), dto); + return card ? CardResponse.from(card) : null; + } + + @Delete(':cardId') + async remove( + @Headers('X-USER-ID') _userId: string, + @Param('cardId') cardId: string, + ): Promise { + return this.cardUseCase.remove(parseInt(cardId)); + } +} diff --git a/src/cardset/infrastructure/http/cardset.controller.ts b/src/cardset/infrastructure/http/cardset.controller.ts new file mode 100644 index 0000000..f1c441a --- /dev/null +++ b/src/cardset/infrastructure/http/cardset.controller.ts @@ -0,0 +1,82 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + Headers, +} from '@nestjs/common'; +import { CardsetUseCase } from '../../application/cardset.use-case'; +import { CreateCardsetRequest } from '../../application/dto/request/create-cardset.request'; +import { UpdateCardsetRequest } from '../../application/dto/request/update-cardset.request'; +import { CardsetCreateResponse } from '../../application/dto/response/cardset-create.response'; +import { CardsetResponse } from '../../application/dto/response/cardset.response'; + +@Controller('cardsets') +export class CardsetController { + constructor(private readonly cardsetUseCase: CardsetUseCase) {} + + @Post() + async create( + @Headers('X-USER-ID') userId: string, + @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 { + const cardsets = await this.cardsetUseCase.findAll(); + return cardsets.map((c) => CardsetResponse.from(c)); + } + + @Get(':cardsetId') + async findOne( + @Headers('X-USER-ID') _userId: string, + @Param('cardsetId') cardsetId: string, + ): Promise { + const cardset = await this.cardsetUseCase.findOne(parseInt(cardsetId)); + return cardset ? CardsetResponse.from(cardset) : null; + } + + @Put(':cardsetId') + async update( + @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(':cardsetId') + async remove( + @Headers('X-USER-ID') userId: string, + @Param('cardsetId') cardsetId: string, + ): Promise { + return this.cardsetUseCase.remove(parseInt(cardsetId), parseInt(userId)); + } + + @Put(':cardsetId/card-count') + async updateCardCount( + @Headers('X-USER-ID') userId: string, + @Param('cardsetId') cardsetId: string, + @Body() body: { cardCount: number }, + ): Promise { + const cardset = await this.cardsetUseCase.updateCardCount( + parseInt(cardsetId), + parseInt(userId), + body.cardCount, + ); + return cardset ? CardsetResponse.from(cardset) : null; + } +} 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..cc3bfd4 --- /dev/null +++ b/src/cardset/infrastructure/persistence/card.repository.impl.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/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'; +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, manager?: EntityManager): Promise { + const repo = manager ? manager.getRepository(CardOrmEntity) : this.ormRepository; + const ormData = CardMapper.toOrm(card); + const created = repo.create(ormData); + const saved = await repo.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, manager?: EntityManager): Promise { + const repo = manager ? manager.getRepository(CardOrmEntity) : this.ormRepository; + await repo.delete(id); + } + + 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 new file mode 100644 index 0000000..f93a91f --- /dev/null +++ b/src/cardset/infrastructure/persistence/cardset-manager.repository.impl.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/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'; +import { CardsetManagerMapper } from './mapper/cardset-manager.mapper'; + +@Injectable() +export class CardsetManagerRepositoryImpl implements ICardsetManagerRepository { + constructor( + @InjectRepository(CardsetManagerOrmEntity) + private readonly ormRepository: Repository, + ) {} + + async save(cardsetManager: CardsetManager, manager?: EntityManager): Promise { + const repo = manager ? manager.getRepository(CardsetManagerOrmEntity) : this.ormRepository; + const ormData = CardsetManagerMapper.toOrm(cardsetManager); + const created = repo.create(ormData); + const saved = await repo.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/cardset.repository.impl.ts b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts new file mode 100644 index 0000000..bd4d9e3 --- /dev/null +++ b/src/cardset/infrastructure/persistence/cardset.repository.impl.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/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'; +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, manager?: EntityManager): Promise { + const repo = manager ? manager.getRepository(CardsetOrmEntity) : this.ormRepository; + const ormData = CardsetMapper.toOrm(cardset); + const created = repo.create(ormData); + const saved = await repo.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-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, + }; + } +} 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..a730fa1 --- /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.visibility, + orm.category, + orm.hashtag ?? null, + Number(orm.imageRefId), + orm.cardCount, + orm.createdAt, + orm.updatedAt, + orm.deletedAt, + ); + } + + static toOrm(domain: Cardset): Partial { + return { + id: domain.id || undefined, + name: domain.name, + groupId: domain.groupId, + visibility: domain.visibility, + category: domain.category, + hashtag: domain.hashtag, + imageRefId: domain.imageRefId, + 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/infrastructure/persistence/orm/cardset.orm-entity.ts b/src/cardset/infrastructure/persistence/orm/cardset.orm-entity.ts new file mode 100644 index 0000000..d539252 --- /dev/null +++ b/src/cardset/infrastructure/persistence/orm/cardset.orm-entity.ts @@ -0,0 +1,35 @@ +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 { + @PrimaryGeneratedColumn({ type: 'int' }) + id!: number; + + @Column({ type: 'varchar', length: 50, nullable: false }) + name!: string; + + @Column({ name: 'group_id', type: 'int', nullable: false }) + groupId!: number; + + @Column({ + type: 'enum', + enum: Visibility, + nullable: false, + default: Visibility.PRIVATE, + }) + visibility!: Visibility; + + @Column({ type: 'varchar', length: 50, nullable: false }) + category!: string; + + @Column({ type: 'varchar', nullable: true }) + hashtag?: string | null; + + @Column({ name: 'image_ref_id', type: 'bigint', nullable: false }) + imageRefId!: number; + + @Column({ name: 'card_count', type: 'int', default: 10 }) + cardCount!: 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/collaboration/infrastructure/persistence/orm/yjs-document.orm-entity.ts b/src/collaboration/infrastructure/persistence/orm/yjs-document.orm-entity.ts new file mode 100644 index 0000000..f66c8c4 --- /dev/null +++ b/src/collaboration/infrastructure/persistence/orm/yjs-document.orm-entity.ts @@ -0,0 +1,28 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('yjs_documents') +export class YjsDocumentOrmEntity { + @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/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 020b9f3..0000000 --- a/src/decorators/ws-user.decorator.ts +++ /dev/null @@ -1,9 +0,0 @@ -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; - }, -); 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..ecf86ba --- /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; + string visibility = 4; + string category = 5; + string hashtag = 6; + 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..329997a --- /dev/null +++ b/src/proto/group.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package group.v1; + +service GroupCommandService { + rpc GetGroupName (GetGroupNameRequest) returns (GetGroupNameResponse); + rpc CheckUserInGroup (CheckUserInGroupRequest) returns (CheckUserInGroupResponse); +} + +message GetGroupNameRequest { + int64 group_id = 1; +} + +message GetGroupNameResponse { + string group_name = 1; +} + +message CheckUserInGroupRequest { + int64 group_id = 1; + int64 user_id = 2; +} + +message CheckUserInGroupResponse { + bool exists = 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/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/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..c9cd65f --- /dev/null +++ b/src/shared/grpc/grpc-client.module.ts @@ -0,0 +1,39 @@ +import { Module } from '@nestjs/common'; +import { ClientsModule, Transport } from '@nestjs/microservices'; +import { join } from 'path'; + +@Module({ + imports: [ + ClientsModule.register([ + { + name: 'GROUP_GRPC_CLIENT', + transport: Transport.GRPC, + options: { + 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', + }, + }, + ]), + ], + 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..d26e3db --- /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; + visibility: string; + category: string; + hashtag: string; + imageRefId: number; + cardCount: number; +} + +// ── 서비스 클라이언트 인터페이스 ──────────────────────────────────── +// 다른 서비스에서 이 서버를 호출할 때 사용하는 클라이언트 타입 + +export interface CardsetServiceClient { + getCardset(data: GetCardsetRequest): Observable; +} diff --git a/src/shared/infrastructure/persistence/base.orm-entity.ts b/src/shared/infrastructure/persistence/base.orm-entity.ts new file mode 100644 index 0000000..0ba11c4 --- /dev/null +++ b/src/shared/infrastructure/persistence/base.orm-entity.ts @@ -0,0 +1,12 @@ +import { CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; + +export abstract class BaseOrmEntity { + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; + + @DeleteDateColumn({ name: 'deleted_at' }) + deletedAt?: Date; +} diff --git a/src/types/userAuth.type.ts b/src/shared/types/user-auth.type.ts similarity index 100% rename from src/types/userAuth.type.ts rename to src/shared/types/user-auth.type.ts 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 f791b30..0000000 --- a/src/websocket/websocket.gateway.ts +++ /dev/null @@ -1,373 +0,0 @@ -import { - WebSocketGateway, - WebSocketServer, - SubscribeMessage, - OnGatewayConnection, - OnGatewayDisconnect, - MessageBody, - ConnectedSocket, -} from '@nestjs/websockets'; -import { Server, Socket } from 'socket.io'; -import { Logger, UseGuards } from '@nestjs/common'; -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'; -@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(); // documentId -> 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의 내장 하트비트가 자동으로 연결 상태를 관리합니다 - } - - @SubscribeMessage('joinRoom') - handleJoinDocument( - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; userId?: string }, - ) { - try { - this.logger.log( - `Client ${client.id} joining document: ${data.documentId}`, - ); - - // 클라이언트를 룸에 조인 - void client.join(data.documentId); - - // Yjs 문서 초기화 또는 가져오기 - let doc = this.documentMap.get(data.documentId); - if (!doc) { - doc = new Y.Doc(); - this.documentMap.set(data.documentId, doc); - } - - // 클라이언트에게 현재 문서 상태 전송 - 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(), - }); - - // 다른 클라이언트들에게 새 클라이언트 알림 - client.to(data.documentId).emit('user-joined', { - clientId: client.id, - userId: data.userId, - timestamp: new Date().toISOString(), - }); - } catch (error) { - this.logger.error( - `Error joining document: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - client.emit('error', { message: 'Failed to join document' }); - } - } - - @SubscribeMessage('leaveRoom') - handleLeaveDocument( - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string }, - ) { - try { - this.logger.log( - `Client ${client.id} leaving document: ${data.documentId}`, - ); - - // 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(), - }); - } catch (error) { - this.logger.error( - `Error leaving document: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - client.emit('error', { message: 'Failed to leave document' }); - } - } - - @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( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() - data: { documentId: string; syncStep: number; update?: 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); - - if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); - return; - } - - this.handleSyncMessage(client, doc, data.documentId, data); - } catch (error) { - this.logger.error( - `Error processing sync from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - - @SubscribeMessage('update') - handleUpdate( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; 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); - - if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document not found' }); - return; - } - - this.handleUpdateMessage(client, doc, data.documentId, data); - } catch (error) { - this.logger.error( - `Error processing update from client ${client.id}: ${error instanceof Error ? error.message : 'Unknown error'}`, - ); - } - } - - @SubscribeMessage('awareness') - handleAwareness( - @WsUser() user: UserAuth, - @ConnectedSocket() client: Socket, - @MessageBody() data: { documentId: string; awareness: 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); - - if (!doc) { - this.logger.warn( - `Document not found: ${data.documentId} for client ${client.id}`, - ); - client.emit('error', { message: 'Document 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), - }); - } 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), - }); - } - - 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); - } 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(), - }); - } - } -} diff --git a/src/websocket/websocket.module.ts b/src/websocket/websocket.module.ts deleted file mode 100644 index a6c3327..0000000 --- a/src/websocket/websocket.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CollaborationGateway } from './websocket.gateway'; - -@Module({ - providers: [CollaborationGateway], - exports: [], -}) -export class WebSocketModule {} 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 @@ + + + + + + 카드셋 실시간 협업 테스트 + + + + +

카드셋 실시간 협업 테스트

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

카드셋 목록

+
+ +

카드 개수 변경

+ + +
+ +
+

카드 목록

+
+
+
+ +
+ + + +