├── .dockerignore ├── .eslintrc.js ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── deploy.sh ├── docker-compose.yml ├── nest-cli.json ├── nginx ├── Dockerfile └── nginx.conf ├── package-lock.json ├── package.json ├── src ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── auth │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── dto │ │ └── requests │ │ │ ├── createKakaoUser.dto.ts │ │ │ └── updateKakaoUser.dto.ts │ ├── strategy │ │ └── jwt.strategy.ts │ └── test │ │ ├── auth.controller.spec.ts │ │ └── auth.service.spec.ts ├── common │ ├── decorators │ │ ├── pagination.decorators.ts │ │ └── user.decorators.ts │ ├── exceptions │ │ └── httpExceptionFilter.ts │ ├── guards │ │ └── jwtAuth.guard.ts │ ├── middlewares │ │ └── logger.middleware.ts │ └── paginations │ │ ├── pagination.request.ts │ │ ├── pagination.response.ts │ │ ├── pagination.swagger.ts │ │ └── paginationBuilder.response.ts ├── domain │ ├── friend │ │ ├── dto │ │ │ └── response │ │ │ │ └── responseFriend.dto.ts │ │ ├── entities │ │ │ └── friend.entity.ts │ │ ├── friend.controller.ts │ │ ├── friend.module.ts │ │ └── friend.service.ts │ ├── kakao │ │ ├── kakao.module.ts │ │ ├── kakao.service.ts │ │ ├── kakaoToken.ts │ │ └── repository │ │ │ └── kakaoToken.memory.repository.ts │ ├── letter │ │ ├── dto │ │ │ ├── requests │ │ │ │ ├── createDraftLetter.request.dto.ts │ │ │ │ ├── createExternalLetter.request.dto.ts │ │ │ │ ├── createExternalLetterImg.request.dto.ts │ │ │ │ ├── findAllLetter.request.dto.ts │ │ │ │ └── kakaoCallback.request.dto.ts │ │ │ └── responses │ │ │ │ ├── letterDetail.response.dto.ts │ │ │ │ ├── letterStorage.response.dto.ts │ │ │ │ └── receviedTempLetter.response.dto.ts │ │ ├── entities │ │ │ ├── letterBody.entity.ts │ │ │ ├── receivedLetter.entity.ts │ │ │ └── sendLetter.entity.ts │ │ ├── letter.constants.ts │ │ ├── letter.controller.ts │ │ ├── letter.module.ts │ │ ├── letter.received.service.ts │ │ ├── letter.sent.controller.ts │ │ ├── letter.sent.service.ts │ │ ├── letter.service.ts │ │ ├── letter.utils.ts │ │ ├── lettter.received.controller.ts │ │ ├── repository │ │ │ └── tempLetter.repository.ts │ │ └── test │ │ │ ├── letter.controller.spec.ts │ │ │ └── letter.service.spec.ts │ ├── notice │ │ ├── dto │ │ │ ├── createNotice.dto.ts │ │ │ ├── deleteNotice.dto.ts │ │ │ └── updateNotice.dto.ts │ │ ├── entities │ │ │ └── notice.entity.ts │ │ ├── notice.controller.ts │ │ ├── notice.module.ts │ │ ├── notice.service.ts │ │ └── test │ │ │ ├── notice.controller.spec.ts │ │ │ └── notice.service.spec.ts │ ├── relationship │ │ └── entities │ │ │ └── relationship.entity.ts │ ├── reminder │ │ ├── dto │ │ │ ├── requests │ │ │ │ ├── createReminder.request.dto.ts │ │ │ │ ├── findAllReminder.request.dto.ts │ │ │ │ └── updateReminder.request.dto.ts │ │ │ └── responses │ │ │ │ ├── reminder.response.dto.ts │ │ │ │ └── reminderStatus.response.dto.ts │ │ ├── entities │ │ │ └── reminder.entity.ts │ │ ├── reminder.controller.ts │ │ ├── reminder.module.ts │ │ ├── reminder.service.ts │ │ └── test │ │ │ ├── reminder.controller.spec.ts │ │ │ └── reminder.service.spec.ts │ ├── reply │ │ ├── dtos │ │ │ └── requests │ │ │ │ └── createReply.request.dto.ts │ │ ├── entities │ │ │ └── reply.entity.ts │ │ ├── reply.controller.ts │ │ ├── reply.module.ts │ │ └── reply.service.ts │ ├── sentence │ │ ├── dto │ │ │ ├── requests │ │ │ │ └── createSentence.request.dto.ts │ │ │ └── responses │ │ │ │ ├── manysentence.response.dto.ts │ │ │ │ └── sentence.response.dto.ts │ │ ├── entities │ │ │ └── sentence.entity.ts │ │ ├── sentence.constant.ts │ │ ├── sentence.controller.ts │ │ ├── sentence.module.ts │ │ ├── sentence.service.ts │ │ └── test │ │ │ ├── sentence.controller.spec.ts │ │ │ └── sentence.service.spec.ts │ └── users │ │ ├── dto │ │ ├── requests │ │ │ └── updateUser.dto.ts │ │ └── response │ │ │ └── user.response.dto.ts │ │ ├── entities │ │ ├── social.entity.ts │ │ ├── user.entity.ts │ │ └── userInfo.entity.ts │ │ ├── users.controller.spec.ts │ │ ├── users.controller.ts │ │ ├── users.module.ts │ │ ├── users.service.spec.ts │ │ └── users.service.ts ├── infrastructor │ ├── S3 │ │ └── s3.multer.ts │ ├── document │ │ └── document.swagger.ts │ └── monitoring │ │ └── monitoring.telegram.ts └── main.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test & Test Coverage CI 2 | 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: 7 | branches: ['master'] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | - run: npm i 25 | continue-on-error: true 26 | - run: npm run format 27 | continue-on-error: true 28 | - run: npm test 29 | continue-on-error: true 30 | - run: npm run test:cov 31 | continue-on-error: true 32 | 33 | - name: Coveralls 34 | uses: coverallsapp/github-action@master 35 | continue-on-error: true 36 | with: 37 | github-token: ${{ secrets.GITHUB_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff: 7 | .idea/**/workspace.xml 8 | .idea/**/tasks.xml 9 | .idea/dictionaries 10 | 11 | # Sensitive or high-churn files: 12 | .idea/**/dataSources/ 13 | .idea/**/dataSources.ids 14 | .idea/**/dataSources.xml 15 | .idea/**/dataSources.local.xml 16 | .idea/**/sqlDataSources.xml 17 | .idea/**/dynamic.xml 18 | .idea/**/uiDesigner.xml 19 | 20 | # Gradle: 21 | .idea/**/gradle.xml 22 | .idea/**/libraries 23 | 24 | # CMake 25 | cmake-build-debug/ 26 | 27 | # Mongo Explorer plugin: 28 | .idea/**/mongoSettings.xml 29 | 30 | ## File-based project format: 31 | *.iws 32 | 33 | ## Plugin-specific files: 34 | 35 | # IntelliJ 36 | out/ 37 | 38 | # mpeltonen/sbt-idea plugin 39 | .idea_modules/ 40 | 41 | # JIRA plugin 42 | atlassian-ide-plugin.xml 43 | 44 | # Cursive Clojure plugin 45 | .idea/replstate.xml 46 | 47 | # Crashlytics plugin (for Android Studio and IntelliJ) 48 | com_crashlytics_export_strings.xml 49 | crashlytics.properties 50 | crashlytics-build.properties 51 | fabric.properties 52 | ### VisualStudio template 53 | ## Ignore Visual Studio temporary files, build results, and 54 | ## files generated by popular Visual Studio add-ons. 55 | ## 56 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 57 | 58 | # User-specific files 59 | *.suo 60 | *.user 61 | *.userosscache 62 | *.sln.docstates 63 | 64 | # User-specific files (MonoDevelop/Xamarin Studio) 65 | *.userprefs 66 | 67 | # Build results 68 | [Dd]ebug/ 69 | [Dd]ebugPublic/ 70 | [Rr]elease/ 71 | [Rr]eleases/ 72 | x64/ 73 | x86/ 74 | bld/ 75 | [Bb]in/ 76 | [Oo]bj/ 77 | [Ll]og/ 78 | 79 | # Visual Studio 2015 cache/options directory 80 | .vs/ 81 | # Uncomment if you have tasks that create the project's static files in wwwroot 82 | #wwwroot/ 83 | 84 | # MSTest test Results 85 | [Tt]est[Rr]esult*/ 86 | [Bb]uild[Ll]og.* 87 | 88 | # NUNIT 89 | *.VisualState.xml 90 | TestResult.xml 91 | 92 | # Build Results of an ATL Project 93 | [Dd]ebugPS/ 94 | [Rr]eleasePS/ 95 | dlldata.c 96 | 97 | # Benchmark Results 98 | BenchmarkDotNet.Artifacts/ 99 | 100 | # .NET Core 101 | project.lock.json 102 | project.fragment.lock.json 103 | artifacts/ 104 | **/Properties/launchSettings.json 105 | 106 | *_i.c 107 | *_p.c 108 | *_i.h 109 | *.ilk 110 | *.meta 111 | *.obj 112 | *.pch 113 | *.pdb 114 | *.pgc 115 | *.pgd 116 | *.rsp 117 | *.sbr 118 | *.tlb 119 | *.tli 120 | *.tlh 121 | *.tmp 122 | *.tmp_proj 123 | *.log 124 | *.vspscc 125 | *.vssscc 126 | .builds 127 | *.pidb 128 | *.svclog 129 | *.scc 130 | 131 | # Chutzpah Test files 132 | _Chutzpah* 133 | 134 | # Visual C++ cache files 135 | ipch/ 136 | *.aps 137 | *.ncb 138 | *.opendb 139 | *.opensdf 140 | *.sdf 141 | *.cachefile 142 | *.VC.db 143 | *.VC.VC.opendb 144 | 145 | # Visual Studio profiler 146 | *.psess 147 | *.vsp 148 | *.vspx 149 | *.sap 150 | 151 | # Visual Studio Trace Files 152 | *.e2e 153 | 154 | # TFS 2012 Local Workspace 155 | $tf/ 156 | 157 | # Guidance Automation Toolkit 158 | *.gpState 159 | 160 | # ReSharper is a .NET coding add-in 161 | _ReSharper*/ 162 | *.[Rr]e[Ss]harper 163 | *.DotSettings.user 164 | 165 | # JustCode is a .NET coding add-in 166 | .JustCode 167 | 168 | # TeamCity is a build add-in 169 | _TeamCity* 170 | 171 | # DotCover is a Code Coverage Tool 172 | *.dotCover 173 | 174 | # AxoCover is a Code Coverage Tool 175 | .axoCover/* 176 | !.axoCover/settings.json 177 | 178 | # Visual Studio code coverage results 179 | *.coverage 180 | *.coveragexml 181 | 182 | # NCrunch 183 | _NCrunch_* 184 | .*crunch*.local.xml 185 | nCrunchTemp_* 186 | 187 | # MightyMoose 188 | *.mm.* 189 | AutoTest.Net/ 190 | 191 | # Web workbench (sass) 192 | .sass-cache/ 193 | 194 | # Installshield output folder 195 | [Ee]xpress/ 196 | 197 | # DocProject is a documentation generator add-in 198 | DocProject/buildhelp/ 199 | DocProject/Help/*.HxT 200 | DocProject/Help/*.HxC 201 | DocProject/Help/*.hhc 202 | DocProject/Help/*.hhk 203 | DocProject/Help/*.hhp 204 | DocProject/Help/Html2 205 | DocProject/Help/html 206 | 207 | # Click-Once directory 208 | publish/ 209 | 210 | # Publish Web Output 211 | *.[Pp]ublish.xml 212 | *.azurePubxml 213 | # Note: Comment the next line if you want to checkin your web deploy settings, 214 | # but database connection strings (with potential passwords) will be unencrypted 215 | *.pubxml 216 | *.publishproj 217 | 218 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 219 | # checkin your Azure Web App publish settings, but sensitive information contained 220 | # in these scripts will be unencrypted 221 | PublishScripts/ 222 | 223 | # NuGet Packages 224 | *.nupkg 225 | # The packages folder can be ignored because of Package Restore 226 | **/[Pp]ackages/* 227 | # except build/, which is used as an MSBuild target. 228 | !**/[Pp]ackages/build/ 229 | # Uncomment if necessary however generally it will be regenerated when needed 230 | #!**/[Pp]ackages/repositories.config 231 | # NuGet v3's project.json files produces more ignorable files 232 | *.nuget.props 233 | *.nuget.targets 234 | 235 | # Microsoft Azure Build Output 236 | csx/ 237 | *.build.csdef 238 | 239 | # Microsoft Azure Emulator 240 | ecf/ 241 | rcf/ 242 | 243 | # Windows Store app package directories and files 244 | AppPackages/ 245 | BundleArtifacts/ 246 | Package.StoreAssociation.xml 247 | _pkginfo.txt 248 | *.appx 249 | 250 | # Visual Studio cache files 251 | # files ending in .cache can be ignored 252 | *.[Cc]ache 253 | # but keep track of directories ending in .cache 254 | !*.[Cc]ache/ 255 | 256 | # Others 257 | ClientBin/ 258 | ~$* 259 | *~ 260 | *.dbmdl 261 | *.dbproj.schemaview 262 | *.jfm 263 | *.pfx 264 | *.publishsettings 265 | orleans.codegen.cs 266 | 267 | # Since there are multiple workflows, uncomment next line to ignore bower_components 268 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 269 | #bower_components/ 270 | 271 | # RIA/Silverlight projects 272 | Generated_Code/ 273 | 274 | # Backup & report files from converting an old project file 275 | # to a newer Visual Studio version. Backup files are not needed, 276 | # because we have git ;-) 277 | _UpgradeReport_Files/ 278 | Backup*/ 279 | UpgradeLog*.XML 280 | UpgradeLog*.htm 281 | 282 | # SQL Server files 283 | *.mdf 284 | *.ldf 285 | *.ndf 286 | 287 | # Business Intelligence projects 288 | *.rdl.data 289 | *.bim.layout 290 | *.bim_*.settings 291 | 292 | # Microsoft Fakes 293 | FakesAssemblies/ 294 | 295 | # GhostDoc plugin setting file 296 | *.GhostDoc.xml 297 | 298 | # Node.js Tools for Visual Studio 299 | .ntvs_analysis.dat 300 | node_modules/ 301 | 302 | # Typescript v1 declaration files 303 | typings/ 304 | 305 | # Visual Studio 6 build log 306 | *.plg 307 | 308 | # Visual Studio 6 workspace options file 309 | *.opt 310 | 311 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 312 | *.vbw 313 | 314 | # Visual Studio LightSwitch build output 315 | **/*.HTMLClient/GeneratedArtifacts 316 | **/*.DesktopClient/GeneratedArtifacts 317 | **/*.DesktopClient/ModelManifest.xml 318 | **/*.Server/GeneratedArtifacts 319 | **/*.Server/ModelManifest.xml 320 | _Pvt_Extensions 321 | 322 | # Paket dependency manager 323 | .paket/paket.exe 324 | paket-files/ 325 | 326 | # FAKE - F# Make 327 | .fake/ 328 | 329 | # JetBrains Rider 330 | .idea/ 331 | *.sln.iml 332 | 333 | # IDE - VSCode 334 | .vscode/* 335 | !.vscode/settings.json 336 | !.vscode/tasks.json 337 | !.vscode/launch.json 338 | !.vscode/extensions.json 339 | 340 | # CodeRush 341 | .cr/ 342 | 343 | # Python Tools for Visual Studio (PTVS) 344 | __pycache__/ 345 | *.pyc 346 | 347 | # Cake - Uncomment if you are using it 348 | # tools/** 349 | # !tools/packages.config 350 | 351 | # Tabs Studio 352 | *.tss 353 | 354 | # Telerik's JustMock configuration file 355 | *.jmconfig 356 | 357 | # BizTalk build output 358 | *.btp.cs 359 | *.btm.cs 360 | *.odx.cs 361 | *.xsd.cs 362 | 363 | # OpenCover UI analysis results 364 | OpenCover/ 365 | coverage/ 366 | 367 | ### macOS template 368 | # General 369 | .DS_Store 370 | .AppleDouble 371 | .LSOverride 372 | 373 | # Icon must end with two \r 374 | Icon 375 | 376 | # Thumbnails 377 | ._* 378 | 379 | # Files that might appear in the root of a volume 380 | .DocumentRevisions-V100 381 | .fseventsd 382 | .Spotlight-V100 383 | .TemporaryItems 384 | .Trashes 385 | .VolumeIcon.icns 386 | .com.apple.timemachine.donotpresent 387 | 388 | # Directories potentially created on remote AFP share 389 | .AppleDB 390 | .AppleDesktop 391 | Network Trash Folder 392 | Temporary Items 393 | .apdisk 394 | 395 | ======= 396 | # Local 397 | .env 398 | dist 399 | 400 | # Logs 401 | logs -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /usr/src/app 4 | COPY package*.json ./ 5 | 6 | ENV TZ=Asia/Seoul 7 | 8 | RUN apt-get update 9 | RUN apt-get install tzdata 10 | RUN npm install -g pm2 11 | RUN npm install 12 | 13 | COPY . ./ 14 | 15 | RUN npm run build 16 | 17 | EXPOSE 3000 18 | CMD ["npm", "run", "start:prod"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled.png?raw=true) 4 | 5 | 6 | ## ✉️ 꼬깃 접어 전하는 마음, ggo-geet ✉️ 7 | 8 | 말로 전하기 어려운 마음을 즐겁게 전할 수 있게 해주는 편지 서비스 9 | 10 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled%201.png?raw=true) 11 | 12 | 소중한 사람에게 마음을 표현하고 싶을 때, 어떻게 전하고 있나요? 13 | 14 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled%202.png?raw=true) 15 | 16 | 번거롭고 귀찮아서, 할 말이 없어서, 뭐라고 적을지 몰라서 등… 17 | 18 | 살아오며 더 이상 편지를 쓰지 않게 된 이유는 아주 많습니다. 19 | 20 | 21 | 22 | 📤 **꼬깃 보내기** 23 | 24 | > **전하고 싶은 마음을 적어보세요.** 25 | 26 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled%203.png?raw=true) 27 | 28 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled%204.png?raw=true) 29 | 30 | 마음을 전하는 상황을 선택해주세요. 31 | 32 | 상황에 맞는 다양한 종이 친구들과 함께, 맞춤형 가이드가 제공됩니다. 33 | 34 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled%205.png?raw=true) 35 | 36 | ✍️ **꼬깃 가이드** 37 | > 38 | > 39 | > 꼬깃을 작성할 때, 사이드와 메모를 통해서 작성에 도움을 받을 수 있어요. 40 | > 41 | > **상황과 유저에 맞는 적합한 문장이 추천**됩니다. 42 | > 43 | 44 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled%206.png?raw=true) 45 | 46 | 작성한 꼬깃은 카카오톡으로 공유되며, 47 | 48 | 회원과 비회원 모두 카카오톡을 통해서 편지를 확인할 수 있습니다. 49 | 50 | 51 | 📭 **꼬깃 보관함** 52 | 53 | >**주고받은 마음을 분류해 보관하세요.** 54 | 55 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled%207.png?raw=true) 56 | 57 | 서비스 내에서 주고 받은 모든 편지와, 58 | 59 | 외부에서 받은 편지를 추가할 수 있습니다. 60 | 61 | 62 | 📝 **꼬깃 메모** 63 | 64 | > **전하려는 마음을 잊지 마세요.** 65 | 66 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled%208.png?raw=true) 67 | 68 | 69 | 70 | ## 💻Web Site 71 | 72 | www.ggo-geet.com 73 | 74 | ### Repository 75 | 76 | **Frontend** 77 | 78 | [Github](https://github.com/depromeet/ggogeet-client) 79 | 80 | **Backend** 81 | 82 | [Github](https://github.com/depromeet/ggogeet_backend) 83 | 84 | 85 | ## 🔧 Skill 86 | ### Backend 87 | 88 | - **Stack** 89 | 90 | ![Untitled](https://github.com/seonghun-dev/ReadmeImage/blob/main/src/ggogeet/Untitled%209.png?raw=true) 91 | 92 | 93 | 94 | - **System Architecture** 95 | 96 | ![KakaoTalk_20230112_231800711](https://user-images.githubusercontent.com/76957700/212344998-53693cb4-1977-4932-a050-5e7eb9188285.png) 97 | 98 | **📂 Directory structure** 99 | 100 | ``` 101 | ├── nginx 102 | ├── logs 103 | ├── src/ 104 | │ ├── auth/ 105 | │ │ ├── dto 106 | │ │ └── strategy 107 | │ ├── common/ 108 | │ │ ├── decorators/ 109 | │ │ ├── exceptions/ 110 | │ │ ├── guards/ 111 | │ │ ├── middlewares/ 112 | │ │ ├── paginations/ 113 | │ │ └── pipes/ 114 | │ ├── constants 115 | │ ├── friend/ 116 | │ ├── kakao/ 117 | │ ├── letter/ 118 | │ │ ├── dto/ 119 | │ │ │ ├── requests/ 120 | │ │ │ └── reponses/ 121 | │ │ ├── entities/ 122 | │ │ ├── repository/ 123 | │ │ ├── letter.module.ts 124 | │ │ ├── letter.controller.ts 125 | │ │ └── letter.service.ts 126 | │ ├── notice/ 127 | │ ├── relationship/ 128 | │ ├── letter/ 129 | │ ├── notice/ 130 | │ ├── relationship/ 131 | │ ├── reminders/ 132 | │ ├── reply/ 133 | │ ├── sentence/ 134 | │ ├── users/ 135 | │ ├── utils/ 136 | │ │ ├── index.ts 137 | │ │ ├── s3.multer.ts 138 | │ └ └── document.swagger.ts 139 | ├── test/ 140 | ├── deploy.sh 141 | ├── docker-compose.yml 142 | ├── Dockerfile 143 | └── README.md 144 | 145 | ``` 146 | 147 | ## 🫶 Owner 148 | 149 | 150 | 151 | 152 | 161 | 162 | 171 | 172 | 181 | 182 | 191 | 192 | 193 | 194 | 203 | 204 | 213 | 214 | 223 | 224 | 225 | 226 | 227 | 228 | 237 | 246 | 247 | 256 | 257 | 258 | 259 |
153 | 154 | 155 |
156 | FE 🖥 157 |
158 | 김민수 159 |
160 |
163 | 164 | 165 |
166 | FE 🖥 167 |
168 | 김동규 169 |
170 |
173 | 174 | 175 |
176 | FE 🖥 177 |
178 | 김가은 179 |
180 |
183 | 184 | 185 |
186 | FE 🖥 187 |
188 | 최영광 189 |
190 |
195 | 196 | 197 |
198 | BE 💾 199 |
200 | 정성훈 201 |
202 |
205 | 206 | 207 |
208 | BE 💾 209 |
210 | 유희수 211 |
212 |
215 | 216 | 217 |
218 | BE 💾 219 |
220 | 김문규 221 |
222 |
229 | 230 | 231 |
232 | Design 🎨 233 |
234 | 정지원 235 |
236 |
238 | 239 | 240 |
241 | Design 🎨 242 |
243 | 김혜진 244 |
245 |
248 | 249 | 250 |
251 | Design 🎨 252 |
253 | 김나영 254 |
255 |
260 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd ~/ggogeet_backend 4 | git pull origin master 5 | sudo docker-compose up --build -d -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | backend: 5 | container_name: backend 6 | build: 7 | context: . 8 | dockerfile: Dockerfile 9 | ports: 10 | - "3000:3000" 11 | volumes: 12 | - './logs:/usr/src/app/logs' 13 | stdin_open: true 14 | tty: true 15 | depends_on: 16 | - redis 17 | redis: 18 | image: redis 19 | command: redis-server --port 6379 20 | container_name: redis 21 | hostname: redis 22 | labels: 23 | - "name=redis" 24 | - "mode=standalone" 25 | ports: 26 | - 6379:6379 27 | nginx: 28 | container_name: nginx 29 | build: 30 | context: ./nginx 31 | dockerfile: Dockerfile 32 | restart: always 33 | ports: 34 | - "80:80" 35 | - "443:443" -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx 2 | 3 | RUN rm /etc/nginx/conf.d/default.conf 4 | 5 | COPY nginx.conf /etc/nginx/conf.d/ -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | 5 | root /var/www/html; 6 | 7 | index index.html index.htm index.nginx-debian.html; 8 | 9 | server_name _; 10 | 11 | location / { 12 | proxy_pass http://backend:3000/; 13 | } 14 | 15 | } 16 | 17 | client_max_body_size 50M; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-typescript-starter", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Nest TypeScript starter repository", 6 | "license": "MIT", 7 | "scripts": { 8 | "build": "nest build", 9 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 10 | "start": "nest start", 11 | "start:dev": "nest start --watch", 12 | "start:debug": "nest start --debug --watch", 13 | "start:prod": "pm2-runtime start dist/main.js", 14 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json", 20 | "coveralls": "jest --coverage && coveralls < coverage/lcov.info" 21 | }, 22 | "dependencies": { 23 | "@aws-sdk/client-s3": "^3.222.0", 24 | "@liaoliaots/nestjs-redis": "^9.0.5", 25 | "@nestjs/common": "^9.0.0", 26 | "@nestjs/config": "^2.2.0", 27 | "@nestjs/core": "^9.0.0", 28 | "@nestjs/jwt": "^9.0.0", 29 | "@nestjs/mapped-types": "*", 30 | "@nestjs/passport": "^9.0.0", 31 | "@nestjs/platform-express": "^9.0.0", 32 | "@nestjs/swagger": "^6.1.4", 33 | "@nestjs/typeorm": "^9.0.1", 34 | "@types/passport-jwt": "^3.0.7", 35 | "@types/passport-kakao": "^1.0.0", 36 | "axios": "^1.2.1", 37 | "class-transformer": "^0.5.1", 38 | "class-validator": "^0.13.2", 39 | "ioredis": "^5.2.4", 40 | "multer-s3": "^3.0.1", 41 | "mysql2": "^2.3.3", 42 | "nest-winston": "^1.8.0", 43 | "passport": "^0.6.0", 44 | "passport-jwt": "^4.0.0", 45 | "passport-kakao": "^1.0.1", 46 | "reflect-metadata": "^0.1.13", 47 | "rxjs": "^7.5.5", 48 | "swagger-ui-express": "^4.6.0", 49 | "typeorm": "^0.3.10", 50 | "winston": "^3.8.2", 51 | "winston-daily-rotate-file": "^4.7.1" 52 | }, 53 | "devDependencies": { 54 | "@nestjs/cli": "^9.0.0", 55 | "@nestjs/schematics": "^9.0.0", 56 | "@nestjs/testing": "^9.2.0", 57 | "@types/express": "^4.17.13", 58 | "@types/jest": "^28.1.4", 59 | "@types/multer-s3": "^3.0.0", 60 | "@types/node": "^18.0.3", 61 | "@types/supertest": "^2.0.12", 62 | "@typescript-eslint/eslint-plugin": "^5.30.5", 63 | "@typescript-eslint/parser": "^5.30.5", 64 | "coveralls": "^3.1.1", 65 | "eslint": "^8.19.0", 66 | "eslint-config-prettier": "^8.5.0", 67 | "eslint-plugin-prettier": "^4.2.1", 68 | "jest": "^28.1.3", 69 | "prettier": "^2.7.1", 70 | "source-map-support": "^0.5.21", 71 | "supertest": "^6.2.4", 72 | "ts-jest": "^28.0.5", 73 | "ts-loader": "^9.3.1", 74 | "ts-node": "^10.8.2", 75 | "tsconfig-paths": "^4.0.0", 76 | "typescript": "^4.7.4" 77 | }, 78 | "jest": { 79 | "moduleFileExtensions": [ 80 | "js", 81 | "json", 82 | "ts" 83 | ], 84 | "rootDir": "src", 85 | "moduleNameMapper": { 86 | "^src/(.*)$": "/$1" 87 | }, 88 | "testRegex": ".*\\.spec\\.ts$", 89 | "transform": { 90 | "^.+\\.(t|j)s$": "ts-jest" 91 | }, 92 | "collectCoverageFrom": [ 93 | "**/*.{!(module|dto|entity|constants),}.(t|j)s" 94 | ], 95 | "coverageDirectory": "../coverage", 96 | "testEnvironment": "node" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | 5 | @Controller() 6 | @ApiTags('Default API') 7 | export class AppController { 8 | constructor(private readonly appService: AppService) {} 9 | 10 | @Get() 11 | @ApiOperation({ 12 | summary: 'Health Check API', 13 | description: 'Health Check API', 14 | }) 15 | getHello(): string { 16 | return this.appService.getHello(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { UsersModule } from './domain/users/users.module'; 5 | import { ConfigModule } from '@nestjs/config'; 6 | import { NoticeModule } from './domain/notice/notice.module'; 7 | import { TypeOrmModule } from '@nestjs/typeorm'; 8 | import { DataSource } from 'typeorm'; 9 | import { Notice } from './domain/notice/entities/notice.entity'; 10 | import { AuthModule } from './auth/auth.module'; 11 | import { User } from './domain/users/entities/user.entity'; 12 | import { UserInfo } from './domain/users/entities/userInfo.entity'; 13 | import { Social } from './domain/users/entities/social.entity'; 14 | import { Friend } from './domain/friend/entities/friend.entity'; 15 | import { LetterModule } from './domain/letter/letter.module'; 16 | import { ReceivedLetter } from './domain/letter/entities/receivedLetter.entity'; 17 | import { ReminderModule } from './domain/reminder/reminder.module'; 18 | import { Reminder } from './domain/reminder/entities/reminder.entity'; 19 | import { LetterBody } from './domain/letter/entities/letterBody.entity'; 20 | import { Reply } from './domain/reply/entities/reply.entity'; 21 | import { Relationship } from './domain/relationship/entities/relationship.entity'; 22 | import { SendLetter } from './domain/letter/entities/sendLetter.entity'; 23 | import { SentenceModule } from './domain/sentence/sentence.module'; 24 | import { Sentence } from './domain/sentence/entities/sentence.entity'; 25 | import { ReplyModule } from './domain/reply/reply.module'; 26 | import { LoggerMiddleware } from './common/middlewares/logger.middleware'; 27 | import { RedisModule } from '@liaoliaots/nestjs-redis'; 28 | import * as winston from 'winston'; 29 | import { 30 | utilities as nestWinstonModuleUtilities, 31 | WinstonModule, 32 | } from 'nest-winston'; 33 | import { FriendModule } from './domain/friend/friend.module'; 34 | import winstonDaily from 'winston-daily-rotate-file'; 35 | import { APP_FILTER } from '@nestjs/core'; 36 | import { HttpExceptionFilter } from './common/exceptions/httpExceptionFilter'; 37 | import { KakaoModule } from './domain/kakao/kakao.module'; 38 | 39 | const ConfigSettingModule = ConfigModule.forRoot({ 40 | isGlobal: true, 41 | }); 42 | 43 | const TypeOrmSettingModule = TypeOrmModule.forRoot({ 44 | type: 'mysql', 45 | host: process.env.MYSQL_HOST, 46 | port: parseInt(process.env.MYSQL_PORT, 10) || 3306, 47 | username: process.env.MYSQL_USER, 48 | password: process.env.MYSQL_PASSWORD, 49 | database: process.env.MYSQL_DB, 50 | timezone: 'Asia/Seoul', 51 | 52 | entities: [ 53 | Notice, 54 | User, 55 | UserInfo, 56 | Social, 57 | Friend, 58 | ReceivedLetter, 59 | LetterBody, 60 | Reply, 61 | Relationship, 62 | SendLetter, 63 | Reminder, 64 | Sentence, 65 | ], 66 | 67 | synchronize: false, 68 | logging: 'all', 69 | }); 70 | 71 | const RedisSettingModule = RedisModule.forRoot({ 72 | config: { 73 | host: process.env.REDIS_HOST, 74 | port: parseInt(process.env.REDIS_PORT, 10) || 6379, 75 | }, 76 | }); 77 | 78 | const logDir = 'logs'; 79 | 80 | const dailyLoggerOptions = (level: string) => { 81 | return { 82 | level, 83 | datePattern: 'YYYY-MM-DD', 84 | dirname: logDir + `/${level}`, 85 | filename: `${level}-%DATE%.log`, 86 | maxFiles: '14d', 87 | zippedArchive: true, 88 | }; 89 | }; 90 | 91 | const WinstonSettingModule = WinstonModule.forRoot({ 92 | transports: [ 93 | new winston.transports.Console({ 94 | format: winston.format.combine( 95 | winston.format.timestamp(), 96 | nestWinstonModuleUtilities.format.nestLike('ggo-geet', { 97 | prettyPrint: true, 98 | }), 99 | ), 100 | }), 101 | new winstonDaily(dailyLoggerOptions('error')), 102 | new winstonDaily(dailyLoggerOptions('warn')), 103 | new winstonDaily(dailyLoggerOptions('info')), 104 | ], 105 | }); 106 | 107 | @Module({ 108 | imports: [ 109 | ConfigSettingModule, 110 | TypeOrmSettingModule, 111 | RedisSettingModule, 112 | WinstonSettingModule, 113 | UsersModule, 114 | NoticeModule, 115 | AuthModule, 116 | LetterModule, 117 | SentenceModule, 118 | ReminderModule, 119 | ReplyModule, 120 | FriendModule, 121 | KakaoModule, 122 | ], 123 | controllers: [AppController], 124 | providers: [ 125 | AppService, 126 | { 127 | provide: APP_FILTER, 128 | useClass: HttpExceptionFilter, 129 | }, 130 | ], 131 | }) 132 | export class AppModule implements NestModule { 133 | constructor(private dataSource: DataSource) {} 134 | 135 | configure(consumer: MiddlewareConsumer) { 136 | consumer.apply(LoggerMiddleware).forRoutes('*'); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | getHello(): string { 6 | return 'Hello World!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpStatus, 6 | Post, 7 | Query, 8 | Res, 9 | } from '@nestjs/common'; 10 | import { AuthService } from './auth.service'; 11 | import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; 12 | import { ResponseFriendDto } from '../domain/friend/dto/response/responseFriend.dto'; 13 | import { KakaoTokenRepository } from 'src/domain/kakao/repository/kakaoToken.memory.repository'; 14 | import { KakaoToken } from 'src/domain/kakao/kakaoToken'; 15 | import { FriendService } from 'src/domain/friend/friend.service'; 16 | 17 | @Controller('auth') 18 | @ApiTags('Auth API') 19 | export class AuthController { 20 | constructor( 21 | private readonly authService: AuthService, 22 | private readonly friendsService: FriendService, 23 | private readonly kakaoTokenRepository: KakaoTokenRepository, 24 | ) {} 25 | 26 | @ApiOperation({ 27 | summary: '카카오 로그인 API', 28 | description: 29 | '카카오 로그인을 진행합니다. code는 로그인 엑세스토큰 요청을 위해 카카오에서 받은 엑세스코드입니다. callbackurl이 /login인지 확인 필요', 30 | }) 31 | @Post('/login') 32 | @ApiBody({ 33 | schema: { 34 | properties: { 35 | code: { 36 | type: 'string', 37 | example: 'code', 38 | }, 39 | redirectURI: { 40 | type: 'string', 41 | example: 'https://ggo-geet.com/bla/bla', 42 | }, 43 | }, 44 | }, 45 | }) 46 | @ApiResponse({ 47 | status: HttpStatus.OK || HttpStatus.CREATED, 48 | description: 'Jwt Access 토큰과 Refresh 토큰을 반환합니다.', 49 | schema: { 50 | properties: { 51 | accessToken: { 52 | type: 'string', 53 | example: 'accessToken', 54 | description: 'Jwt 토큰', 55 | }, 56 | refreshToken: { 57 | type: 'string', 58 | example: 'refreshToken', 59 | description: 'Refresh 토큰', 60 | }, 61 | allowFriendsList: { 62 | type: 'boolean', 63 | example: 'true', 64 | description: '친구목록,메세지 동의 여부', 65 | }, 66 | }, 67 | }, 68 | }) 69 | async kakaoLogin( 70 | @Body('code') code: string, 71 | @Body('redirectURI') redirectURI: string, 72 | @Res() res, 73 | ) { 74 | // 인증 코드로 카카오 토큰 받아오기 75 | const codeResponse = await this.authService.getKakaoAccessToken( 76 | code, 77 | redirectURI, 78 | ); 79 | 80 | const kakaoToken = new KakaoToken( 81 | codeResponse.access_token, 82 | codeResponse.refresh_token, 83 | codeResponse.expires_in, 84 | codeResponse.refresh_token_expires_in, 85 | ); 86 | 87 | // 토큰으로 사용자 정보 받아오기 88 | const { statusCode, user, allowFriendsList } = 89 | await this.authService.getUserProfile(codeResponse); 90 | await this.kakaoTokenRepository.save(user.id, kakaoToken); 91 | 92 | if (allowFriendsList == true) { 93 | await this.friendsService.updateFriends(user); 94 | } 95 | 96 | // user.id로 jwt 토큰 발급 97 | const jwtAccessToken = await this.authService.getAccessToken(user.id); 98 | const jwtRefreshToken = await this.authService.getRefreshToken(user.id); 99 | 100 | res 101 | .status(statusCode) 102 | .send({ data: { jwtAccessToken, jwtRefreshToken, allowFriendsList } }); 103 | } 104 | 105 | @ApiOperation({ 106 | summary: '카카오 친구목록 API', 107 | description: 108 | '카카오 친구목록 받기 위한 추가 항목 동의를 요청하고 친구목록을 반환합니다.', 109 | }) 110 | @ApiBody({ 111 | description: 112 | 'code는 친구목록 엑세스토큰 요청을 위해 카카오에서 받은 엑세스코드입니다. callbackurl이 /friends인지 확인 필요', 113 | schema: { 114 | properties: { 115 | code: { 116 | type: 'string', 117 | example: 'code', 118 | }, 119 | redirectURI: { 120 | type: 'string', 121 | example: 'https://ggo-geet.com/bla/bla', 122 | }, 123 | }, 124 | }, 125 | }) 126 | @ApiResponse({ 127 | status: HttpStatus.OK, 128 | description: '친구목록을 반환합니다.', 129 | type: [ResponseFriendDto], 130 | }) 131 | // 추가 동의항목 동의 후 친구목록 반환 132 | @Post('/kakao/friends') 133 | async addAgreeCategory( 134 | @Body('code') code: string, 135 | @Body('redirectURI') redirectURI: string, 136 | @Res() res, 137 | ) { 138 | const codeResponse = await this.authService.getKakaoAccessToken( 139 | code, 140 | redirectURI, 141 | ); 142 | 143 | const { user } = await this.authService.getUserProfile(codeResponse); 144 | 145 | const kakaoToken = new KakaoToken( 146 | codeResponse.access_token, 147 | codeResponse.refresh_token, 148 | codeResponse.expires_in, 149 | codeResponse.refresh_token_expires_in, 150 | ); 151 | 152 | await this.kakaoTokenRepository.save(user.id, kakaoToken); 153 | 154 | await this.friendsService.updateFriends(user); 155 | 156 | res.status(HttpStatus.OK).send(); 157 | } 158 | 159 | //----------------------------------------------------------------------------------------------------------- 160 | @ApiOperation({ 161 | summary: '카카오 로그인 테스트용 API', 162 | description: '로그인할때 body에 넣을 code값을 받아옵니다.', 163 | }) 164 | @Get('/code/login') 165 | async getCodeForLoginTest(@Res() res) { 166 | const _hostName = 'https://kauth.kakao.com'; 167 | const _restApiKey = process.env.KAKAO_CLIENT_ID; 168 | // 카카오 로그인 redirectURI 등록 169 | const _redirectUrl = `${process.env.FRONT_HOST}/auth/kakao/login`; 170 | const url = `${_hostName}/oauth/authorize?client_id=${_restApiKey}&redirect_uri=${_redirectUrl}&response_type=code`; 171 | return res.redirect(url); 172 | } 173 | 174 | @ApiOperation({ 175 | summary: '카카오 친구목록 테스트용 API', 176 | description: '친구목록을 가져올때 body에 넣을 code값을 받아옵니다.', 177 | }) 178 | @Get('/code/friends') 179 | async getCodeForFriendsTest(@Res() res) { 180 | const _hostName = 'https://kauth.kakao.com'; 181 | const _restApiKey = process.env.KAKAO_CLIENT_ID; 182 | // 카카오 로그인 redirectURI 등록 183 | const _redirectUrl = `${process.env.FRONT_HOST}/auth/kakao/friends`; 184 | const url = `${_hostName}/oauth/authorize?client_id=${_restApiKey}&redirect_uri=${_redirectUrl}&response_type=code&scope=friends,talk_message`; 185 | return res.redirect(url); 186 | } 187 | 188 | @ApiOperation({ 189 | summary: '카카오 로그인 Redirect API', 190 | description: '카카오 로그인 Redirect API', 191 | }) 192 | @Get('/kakao/login') 193 | async kakaoLoginTest(@Query('code') code: string, @Res() res) { 194 | return res.send({ scope: 'login', code }); 195 | } 196 | 197 | @ApiOperation({ 198 | summary: '카카오 친구목록 Redirect API', 199 | description: '카카오 친구목록 Redirect API', 200 | }) 201 | @Get('/kakao/friends') 202 | async kakaoFriendsTest(@Query('code') code: string, @Res() res) { 203 | return res.send({ scope: 'friends', code }); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { JwtStrategy } from 'src/auth/strategy/jwt.strategy'; 6 | import { Social } from 'src/domain/users/entities/social.entity'; 7 | import { User } from 'src/domain/users/entities/user.entity'; 8 | import { UserInfo } from 'src/domain/users/entities/userInfo.entity'; 9 | import { AuthController } from './auth.controller'; 10 | import { AuthService } from './auth.service'; 11 | import { KakaoService } from 'src/domain/kakao/kakao.service'; 12 | import { Friend } from 'src/domain/friend/entities/friend.entity'; 13 | import { UsersService } from 'src/domain/users/users.service'; 14 | import { FriendService } from 'src/domain/friend/friend.service'; 15 | import { KakaoTokenRepository } from 'src/domain/kakao/repository/kakaoToken.memory.repository'; 16 | 17 | @Module({ 18 | imports: [ 19 | PassportModule, 20 | JwtModule.register({ 21 | secret: process.env.JWT_SECRET, 22 | signOptions: { 23 | expiresIn: process.env.JWT_ACCESS_EXPIRATION_TIME, 24 | }, 25 | }), 26 | TypeOrmModule.forFeature([User, UserInfo, Social, Friend]), 27 | ], 28 | controllers: [AuthController], 29 | providers: [ 30 | AuthService, 31 | JwtStrategy, 32 | KakaoService, 33 | UsersService, 34 | FriendService, 35 | KakaoTokenRepository, 36 | ], 37 | exports: [AuthService, JwtModule, PassportModule, JwtStrategy], 38 | }) 39 | export class AuthModule {} 40 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, InternalServerErrorException } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { Social } from 'src/domain/users/entities/social.entity'; 5 | import { User } from 'src/domain/users/entities/user.entity'; 6 | import { UserInfo } from 'src/domain/users/entities/userInfo.entity'; 7 | import { Repository } from 'typeorm'; 8 | import { CreateKakaoUserDto } from './dto/requests/createKakaoUser.dto'; 9 | import { KakaoService } from 'src/domain/kakao/kakao.service'; 10 | import { UsersService } from 'src/domain/users/users.service'; 11 | 12 | @Injectable() 13 | export class AuthService { 14 | constructor( 15 | @InjectRepository(User) private userRepository: Repository, 16 | private readonly userService: UsersService, 17 | private readonly kakaoService: KakaoService, 18 | private readonly jwtService: JwtService, 19 | ) {} 20 | 21 | async getKakaoAccessToken(code: string, redirectURI: string) { 22 | return await this.kakaoService.getKakaoAccessToken(code, redirectURI); 23 | } 24 | 25 | async getUserProfile(codeResponse) { 26 | try { 27 | const profileJson = await this.kakaoService.getKakaoProfile( 28 | codeResponse.access_token, 29 | ); 30 | const kakao_account = profileJson.kakao_account; 31 | 32 | const kakaoInfo: CreateKakaoUserDto = { 33 | kakaoId: profileJson.id, 34 | nickname: profileJson.properties.nickname, 35 | profile_image: profileJson.properties.profile_image, 36 | email: 37 | kakao_account.has_email && !kakao_account.kakao_email_needs_agreement 38 | ? kakao_account.email 39 | : null, 40 | age_range: 41 | kakao_account.has_age_range && 42 | !kakao_account.kakao_age_range_needs_agreement 43 | ? kakao_account.age_range 44 | : null, 45 | birthday: 46 | kakao_account.has_birthday && 47 | !kakao_account.kakao_birthday_needs_agreement 48 | ? kakao_account.birthday 49 | : null, 50 | gender: 51 | kakao_account.has_gender && 52 | !kakao_account.kakao_gender_needs_agreement 53 | ? kakao_account.gender 54 | : null, 55 | allow_scope: codeResponse.scope, 56 | is_allow_friend: codeResponse.scope.indexOf('friends') !== -1, 57 | }; 58 | 59 | // 존재하는 유저인지 확인 후 유저 정보 반환 60 | const user = await this.validateKakao(kakaoInfo); 61 | return user; 62 | } catch (e) { 63 | throw new InternalServerErrorException(e); 64 | } 65 | } 66 | 67 | async validateKakao(kakaoInfo: CreateKakaoUserDto) { 68 | const kakaoId: number = parseInt(kakaoInfo.kakaoId); 69 | let exUser = await this.userService.findUserBySocialId(kakaoId); 70 | 71 | // 새로 가입한 유저면 create 72 | if (!exUser) { 73 | const newSocial = new Social(); 74 | newSocial.clientId = kakaoInfo.kakaoId; 75 | newSocial.allowFriendsList = kakaoInfo.is_allow_friend; 76 | 77 | const newUserInfo = new UserInfo(); 78 | newUserInfo.email = kakaoInfo.email; 79 | newUserInfo.birthday = kakaoInfo.birthday; 80 | newUserInfo.email = kakaoInfo.email; 81 | newUserInfo.gender = kakaoInfo.gender; 82 | 83 | const newUser = new User(); 84 | newUser.name = kakaoInfo.nickname; 85 | newUser.nickname = kakaoInfo.nickname; 86 | newUser.profileImg = kakaoInfo.profile_image; 87 | newUser.social = newSocial; 88 | newUser.userInfo = newUserInfo; 89 | 90 | await this.userRepository.save(newUser); 91 | return { 92 | statusCode: 201, 93 | user: newUser, 94 | allowFriendsList: newUser.social.allowFriendsList, 95 | }; 96 | } else { 97 | // 기존 유저면 update 98 | exUser = await this.updateKakaoUser(exUser.id, kakaoInfo); 99 | return { 100 | statusCode: 200, 101 | user: exUser, 102 | allowFriendsList: exUser.social.allowFriendsList, 103 | }; 104 | } 105 | } 106 | 107 | async getAccessToken(userId: number) { 108 | return this.jwtService.sign( 109 | { id: userId }, 110 | { 111 | secret: process.env.JWT_SECRET, 112 | expiresIn: process.env.JWT_ACCESS_EXPIRATION_TIME, 113 | }, 114 | ); 115 | } 116 | 117 | async getRefreshToken(userId: number) { 118 | return this.jwtService.sign( 119 | { id: userId }, 120 | { 121 | secret: process.env.JWT_SECRET, 122 | expiresIn: process.env.JWT_REFRESH_EXPIRATION_TIME, 123 | }, 124 | ); 125 | } 126 | 127 | async updateKakaoUser(id: number, kakaoInfo: CreateKakaoUserDto) { 128 | const user = await this.userRepository.findOne({ 129 | where: { id: id }, 130 | relations: { social: true }, 131 | }); 132 | user.name = kakaoInfo.nickname; 133 | user.profileImg = kakaoInfo.profile_image; 134 | user.social.allowFriendsList = kakaoInfo.is_allow_friend; 135 | 136 | return await this.userRepository.save(user); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/auth/dto/requests/createKakaoUser.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateKakaoUserDto { 2 | kakaoId: string; 3 | nickname: string; 4 | profile_image: string; 5 | email: string; 6 | age_range: string; 7 | birthday: string; 8 | gender: string; 9 | allow_scope: string; 10 | is_allow_friend: boolean; 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/dto/requests/updateKakaoUser.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CreateKakaoUserDto } from './createKakaoUser.dto'; 3 | 4 | export class UpdateKakaoUserDto extends PartialType(CreateKakaoUserDto) {} 5 | -------------------------------------------------------------------------------- /src/auth/strategy/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { InjectRepository } from '@nestjs/typeorm'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import { User } from 'src/domain/users/entities/user.entity'; 6 | import { Repository } from 'typeorm'; 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy) { 10 | constructor( 11 | @InjectRepository(User) private userRepository: Repository, 12 | ) { 13 | super({ 14 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 15 | ignoreExpiration: false, 16 | secretOrKey: process.env.JWT_SECRET, 17 | }); 18 | } 19 | 20 | async validate(payload) { 21 | const user = await this.userRepository.findOneBy({ id: payload.id }); 22 | 23 | if (user) { 24 | return user; 25 | } else { 26 | throw new UnauthorizedException({ 27 | type: 'UNAUTHORIZED', 28 | message: 'Authentication error.', 29 | }); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/auth/test/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthController } from '../auth.controller'; 2 | 3 | describe('AuthController', () => { 4 | let controller: AuthController; 5 | 6 | it('should be defined', () => { 7 | expect(true).toBe(true); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/auth/test/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { AuthService } from '../auth.service'; 2 | 3 | describe('AuthService', () => { 4 | let service: AuthService; 5 | 6 | it('should be defined', () => { 7 | expect(true).toBe(true); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/common/decorators/pagination.decorators.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { 3 | PaginationDefault, 4 | PaginationRequest, 5 | } from '../paginations/pagination.request'; 6 | 7 | /* 8 | * @GetPagination() is a decorator that returns a PagenationRequest object. 9 | * @GetPagination() pagination: PaginationRequest 10 | */ 11 | export const GetPagination = createParamDecorator( 12 | (_data: any, ctx: ExecutionContext) => { 13 | const request = ctx.switchToHttp().getRequest(); 14 | const page = request.query?.page || PaginationDefault.PAGE_DEFAULT; 15 | const take = request.query?.take || PaginationDefault.TAKE_DEFAULT; 16 | 17 | const pagenationRequest: PaginationRequest = { 18 | page: page, 19 | take: take, 20 | getPage: () => { 21 | return page; 22 | }, 23 | getSkip: () => { 24 | return (page - 1) * take; 25 | }, 26 | getTake: () => { 27 | return take; 28 | }, 29 | }; 30 | 31 | return pagenationRequest; 32 | }, 33 | ); 34 | -------------------------------------------------------------------------------- /src/common/decorators/user.decorators.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const ReqUser = createParamDecorator( 4 | (_data: any, ctx: ExecutionContext) => { 5 | const request = ctx.switchToHttp().getRequest(); 6 | const userObj = request.user; 7 | 8 | return userObj; 9 | }, 10 | ); 11 | -------------------------------------------------------------------------------- /src/common/exceptions/httpExceptionFilter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | Inject, 7 | } from '@nestjs/common'; 8 | import { Request, Response } from 'express'; 9 | import { WINSTON_MODULE_PROVIDER, WinstonLogger } from 'nest-winston'; 10 | import { TelegramMonitoringService } from 'src/infrastructor/monitoring/monitoring.telegram'; 11 | 12 | @Catch() 13 | export class HttpExceptionFilter implements ExceptionFilter { 14 | constructor( 15 | @Inject(WINSTON_MODULE_PROVIDER) 16 | private readonly logger: WinstonLogger, 17 | ) {} 18 | catch(exception: Error, host: ArgumentsHost) { 19 | const ctx = host.switchToHttp(); 20 | const res = ctx.getResponse(); 21 | const req = ctx.getRequest(); 22 | const errorTime = new Date().toLocaleString(); 23 | const monitoring = new TelegramMonitoringService(); 24 | 25 | if (!(exception instanceof HttpException) || res.statusCode >= 500) { 26 | const errorMsg = `[${req.method}] ${req.url} ${res.statusCode} - ${exception} - ${errorTime}`; 27 | monitoring.sendAlert(errorMsg); 28 | this.logger.error(errorMsg); 29 | 30 | return res.status(500).json({ 31 | statusCode: 500, 32 | message: 'Internal Server Error', 33 | error: 'Internal Server Error', 34 | }); 35 | } 36 | 37 | const response = (exception as HttpException).getResponse(); 38 | const status = (exception as HttpException).getStatus(); 39 | 40 | const httpErrorMsg = `[${req.method}] ${req.url} ${status} - ${exception} - ${errorTime}`; 41 | this.logger.error(httpErrorMsg); 42 | 43 | res.status((exception as HttpException).getStatus()).json(response); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/common/guards/jwtAuth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /src/common/middlewares/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, NestMiddleware } from '@nestjs/common'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import { Logger as WinstonLogger } from 'winston'; 4 | import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; 5 | 6 | @Injectable() 7 | export class LoggerMiddleware implements NestMiddleware { 8 | constructor( 9 | @Inject(WINSTON_MODULE_PROVIDER) 10 | private readonly logger: WinstonLogger, 11 | ) {} 12 | 13 | use(req: Request, res: Response, next: NextFunction) { 14 | const { ip, method, originalUrl } = req; 15 | const userAgent = req.get('user-agent') || ''; 16 | 17 | res.on('finish', () => { 18 | const { statusCode } = res; 19 | this.logger.info( 20 | `[${method}] ${originalUrl} ${statusCode} - ${ip} ${userAgent}`, 21 | ); 22 | }); 23 | 24 | next(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/common/paginations/pagination.request.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsOptional } from 'class-validator'; 3 | 4 | export enum PaginationDefault { 5 | PAGE_DEFAULT = 1, 6 | TAKE_DEFAULT = 10, 7 | SKIP_DEFAULT = 0, 8 | } 9 | 10 | export class PaginationRequest { 11 | @Type(() => Number) 12 | @IsOptional() 13 | page?: number = PaginationDefault.PAGE_DEFAULT; 14 | 15 | @Type(() => Number) 16 | @IsOptional() 17 | take?: number = PaginationDefault.TAKE_DEFAULT; 18 | 19 | getSkip() { 20 | return (this.page - 1) * this.take || PaginationDefault.SKIP_DEFAULT; 21 | } 22 | 23 | getPage() { 24 | return this.page; 25 | } 26 | 27 | getTake() { 28 | return this.take; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/common/paginations/pagination.response.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { PaginationBuilder } from './paginationBuilder.response'; 3 | 4 | export class PaginationResponse { 5 | data: T[]; 6 | 7 | @ApiProperty({ 8 | example: { 9 | page: 1, 10 | take: 10, 11 | totalCount: 100, 12 | totalPage: 10, 13 | hasNextPage: true, 14 | }, 15 | description: '페이지네이션관련 메타데이터', 16 | }) 17 | meta: { 18 | page: number; 19 | take: number; 20 | totalCount: number; 21 | totalPage: number; 22 | hasNextPage: boolean; 23 | }; 24 | 25 | constructor(pagenationBuilder: PaginationBuilder) { 26 | this.data = pagenationBuilder._data; 27 | this.meta = { 28 | page: pagenationBuilder._page, 29 | take: pagenationBuilder._take, 30 | totalCount: pagenationBuilder._totalCount, 31 | totalPage: this.getTotalPage( 32 | pagenationBuilder._totalCount, 33 | pagenationBuilder._take, 34 | ), 35 | hasNextPage: this.getHasNextPage( 36 | pagenationBuilder._page, 37 | this.getTotalPage( 38 | pagenationBuilder._totalCount, 39 | pagenationBuilder._take, 40 | ), 41 | ), 42 | }; 43 | } 44 | 45 | private getTotalPage(totalCount: number, take: number): number { 46 | return Math.ceil(totalCount / take); 47 | } 48 | 49 | private getHasNextPage(page: number, totalPage: number): boolean { 50 | return page < totalPage; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/common/paginations/pagination.swagger.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators, Type } from '@nestjs/common'; 2 | import { 3 | ApiExtraModels, 4 | ApiOkResponse, 5 | ApiQuery, 6 | getSchemaPath, 7 | } from '@nestjs/swagger'; 8 | import { PaginationResponse } from './pagination.response'; 9 | 10 | /* 11 | * @ApiPaginatedResponse() is a decorator to create a swagger document for a paginated response. 12 | */ 13 | export const ApiPaginationResponse = (model: TModel) => { 14 | return applyDecorators( 15 | ApiOkResponse({ 16 | schema: { 17 | allOf: [ 18 | { $ref: getSchemaPath(PaginationResponse) }, 19 | { 20 | properties: { 21 | data: { 22 | type: 'array', 23 | items: { $ref: getSchemaPath(model) }, 24 | }, 25 | }, 26 | }, 27 | ], 28 | }, 29 | }), 30 | ApiExtraModels(PaginationResponse), 31 | ApiExtraModels(model), 32 | ); 33 | }; 34 | 35 | /* 36 | * @ApiPaginationRequst() is a decorator to create a swagger document for a paginated request. 37 | */ 38 | export const ApiPaginationRequst = () => { 39 | return applyDecorators( 40 | ApiQuery({ 41 | name: 'page', 42 | required: false, 43 | type: Number, 44 | description: '페이지 번호', 45 | }), 46 | ApiQuery({ 47 | name: 'take', 48 | required: false, 49 | type: Number, 50 | description: '페이지당 아이템 수', 51 | }), 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/common/paginations/paginationBuilder.response.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * PaginationBuilder is a class to create a new PageResponse object 3 | * Using Builder Pattern, To prevent the error of the order of the arguments 4 | * when creating a new PageResponse object 5 | * .setData(data) 6 | * .setPage(page) 7 | * .setTake(take) 8 | * .setTotalCount(totalCount) 9 | * .build() 10 | */ 11 | 12 | import { PaginationResponse } from './pagination.response'; 13 | 14 | export class PaginationBuilder { 15 | _data: T[]; 16 | _page: number; 17 | _take: number; 18 | _totalCount: number; 19 | 20 | setData(data: T[]) { 21 | this._data = data; 22 | return this; 23 | } 24 | 25 | setPage(page: number) { 26 | this._page = page; 27 | return this; 28 | } 29 | 30 | setTake(take: number) { 31 | this._take = take; 32 | return this; 33 | } 34 | 35 | setTotalCount(totalCount: number) { 36 | this._totalCount = totalCount; 37 | return this; 38 | } 39 | 40 | build() { 41 | return new PaginationResponse(this); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/domain/friend/dto/response/responseFriend.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Friend } from 'src/domain/friend/entities/friend.entity'; 3 | 4 | export class ResponseFriendDto { 5 | @ApiProperty({ example: 1, description: '유저 id' }) 6 | id: number; 7 | @ApiProperty({ example: 1, description: '친구 user id' }) 8 | friendUserId: number; 9 | 10 | @ApiProperty({ example: 'kakao-uuid', description: '카카오 uuid' }) 11 | kakaoUuid: string; 12 | 13 | @ApiProperty({ example: '친구 이름', description: '친구 이름' }) 14 | kakaoFriendName: string; 15 | 16 | @ApiProperty({ 17 | example: '친구 프로필 이미지', 18 | description: '친구 프로필 이미지', 19 | }) 20 | friendProfileImg: string; 21 | 22 | @ApiProperty({ example: '2021-01-01 00:00:00', description: '생성일' }) 23 | createdAt: Date; 24 | 25 | @ApiProperty({ example: '2021-01-01 00:00:00', description: '수정일' }) 26 | updatedAt: Date; 27 | 28 | constructor(friend: Friend) { 29 | this.id = friend.id; 30 | this.friendUserId = friend.friendUser.id; 31 | this.friendProfileImg = friend.friendUser.profileImg; 32 | this.kakaoUuid = friend.kakaoUuid; 33 | this.kakaoFriendName = friend.kakaoFriendName; 34 | this.createdAt = friend.createdAt; 35 | this.updatedAt = friend.updatedAt; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/domain/friend/entities/friend.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | JoinColumn, 7 | ManyToOne, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import { User } from '../../users/entities/user.entity'; 12 | 13 | @Entity({ name: 'friend' }) 14 | export class Friend { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column() 19 | kakaoUuid: string; 20 | 21 | @Column() 22 | kakaoFriendName: string; 23 | 24 | @ManyToOne(() => User) 25 | @JoinColumn({ name: 'userId' }) 26 | user: User; 27 | 28 | @Column({ name: 'userId' }) 29 | userId: number; 30 | 31 | @ManyToOne(() => User, { nullable: true }) 32 | @JoinColumn({ name: 'friendUserId' }) 33 | friendUser: User; 34 | 35 | @CreateDateColumn() 36 | createdAt: Date; 37 | 38 | @UpdateDateColumn() 39 | updatedAt: Date; 40 | 41 | @DeleteDateColumn() 42 | deletedAt: Date; 43 | } 44 | -------------------------------------------------------------------------------- /src/domain/friend/friend.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | HttpStatus, 5 | Param, 6 | Patch, 7 | UseGuards, 8 | } from '@nestjs/common'; 9 | import { FriendService } from './friend.service'; 10 | import { JwtAuthGuard } from 'src/common/guards/jwtAuth.guard'; 11 | import { ReqUser } from 'src/common/decorators/user.decorators'; 12 | import { User } from 'src/domain/users/entities/user.entity'; 13 | import { 14 | ApiBearerAuth, 15 | ApiOperation, 16 | ApiResponse, 17 | ApiTags, 18 | } from '@nestjs/swagger'; 19 | import { ResponseFriendDto } from './dto/response/responseFriend.dto'; 20 | 21 | @Controller('friends') 22 | @ApiBearerAuth() 23 | @ApiTags('Friends API') 24 | @UseGuards(JwtAuthGuard) 25 | export class FriendController { 26 | constructor(private readonly friendService: FriendService) {} 27 | 28 | @ApiOperation({ 29 | summary: '카카오 친구목록 가져오기 API', 30 | description: '카카오 친구목록을 가져옵니다.', 31 | }) 32 | @ApiResponse({ 33 | status: HttpStatus.OK, 34 | description: '친구목록을 반환합니다.', 35 | type: [ResponseFriendDto], 36 | }) 37 | @Get() 38 | findAll(@ReqUser() user: User) { 39 | return this.friendService.findAll(user); 40 | } 41 | 42 | @ApiOperation({ 43 | summary: '친구 정보 가져오기 API', 44 | description: '친구 정보를 친구 ID로 가져옵니다.', 45 | }) 46 | @ApiResponse({ 47 | status: HttpStatus.OK, 48 | description: '친구 정보를 반환합니다.', 49 | type: ResponseFriendDto, 50 | }) 51 | @Get('/:id') 52 | findOne(@ReqUser() user: User, @Param('id') id: number) { 53 | return this.friendService.findOne(id, user); 54 | } 55 | 56 | @ApiOperation({ 57 | summary: '카카오 친구 목록 업데이트 API', 58 | description: '친구 목록을 카카오 친구 기반으로 업데이트합니다.', 59 | }) 60 | @Patch('/kakao/update') 61 | updateFriends(@ReqUser() user: User) { 62 | return this.friendService.updateFriends(user); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/domain/friend/friend.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { FriendController } from './friend.controller'; 3 | import { FriendService } from './friend.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { PassportModule } from '@nestjs/passport'; 6 | import { AuthModule } from 'src/auth/auth.module'; 7 | import { Friend } from './entities/friend.entity'; 8 | import { JwtStrategy } from 'src/auth/strategy/jwt.strategy'; 9 | import { User } from 'src/domain/users/entities/user.entity'; 10 | import { KakaoService } from 'src/domain/kakao/kakao.service'; 11 | import { KakaoTokenRepository } from 'src/domain/kakao/repository/kakaoToken.memory.repository'; 12 | 13 | @Module({ 14 | imports: [ 15 | TypeOrmModule.forFeature([User, Friend]), 16 | PassportModule, 17 | AuthModule, 18 | ], 19 | controllers: [FriendController], 20 | providers: [FriendService, JwtStrategy, KakaoService, KakaoTokenRepository], 21 | }) 22 | export class FriendModule {} 23 | -------------------------------------------------------------------------------- /src/domain/friend/friend.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { ResponseFriendDto } from './dto/response/responseFriend.dto'; 4 | import { User } from 'src/domain/users/entities/user.entity'; 5 | import { Friend } from './entities/friend.entity'; 6 | import { Repository } from 'typeorm'; 7 | import { KakaoTokenRepository } from 'src/domain/kakao/repository/kakaoToken.memory.repository'; 8 | import { KakaoToken } from 'src/domain/kakao/kakaoToken'; 9 | import { KakaoService } from 'src/domain/kakao/kakao.service'; 10 | 11 | @Injectable() 12 | export class FriendService { 13 | constructor( 14 | @InjectRepository(Friend) 15 | private readonly friendRepository: Repository, 16 | @InjectRepository(User) 17 | private readonly userRepository: Repository, 18 | private readonly kakaoService: KakaoService, 19 | private readonly kakaoTokenRepository: KakaoTokenRepository, 20 | ) {} 21 | 22 | async findAll(user: User): Promise { 23 | const friendList = await this.friendRepository 24 | .createQueryBuilder('friend') 25 | .leftJoinAndSelect('friend.friendUser', 'user') 26 | .where('friend.userId = :userId', { userId: user.id }) 27 | .getMany(); 28 | console.log(friendList); 29 | const result = friendList.map((friend) => { 30 | return new ResponseFriendDto(friend); 31 | }); 32 | return { 33 | data: result, 34 | }; 35 | } 36 | 37 | async findOne(id: number, user: User): Promise { 38 | const friend = await this.friendRepository 39 | .createQueryBuilder('friend') 40 | .leftJoinAndSelect('friend.friendUser', 'user') 41 | .where('friend.id = :id', { id: id }) 42 | .andWhere('friend.userId = :userId', { userId: user.id }) 43 | .getOne(); 44 | 45 | if (!friend) { 46 | throw new NotFoundException({ 47 | type: 'NOT_FOUND', 48 | message: `Friend #${id} not found`, 49 | }); 50 | } 51 | 52 | return new ResponseFriendDto(friend); 53 | } 54 | 55 | async updateFriends(user: User) { 56 | const kakaoToken: KakaoToken = await this.kakaoTokenRepository.findByUserId( 57 | user.id, 58 | ); 59 | const acessToken = kakaoToken.getAcessToken(); 60 | 61 | if (!acessToken) { 62 | throw new NotFoundException({ 63 | type: 'NOT_FOUND', 64 | message: `카카오 토큰이 없습니다.`, 65 | }); 66 | } 67 | 68 | const [friendsList, friendsCount] = 69 | await this.kakaoService.getKakaoFriendsandCount(acessToken); 70 | if (friendsCount <= 0) { 71 | throw new NotFoundException({ 72 | type: 'NOT_FOUND', 73 | message: `카카오 친구가 없습니다.`, 74 | }); 75 | } 76 | 77 | class FriendsInfo { 78 | kakaoUuid: string; 79 | kakaoFriendName: string; 80 | 81 | constructor(kakaoUuid: string, kakaoFriendName: string) { 82 | this.kakaoUuid = kakaoUuid; 83 | this.kakaoFriendName = kakaoFriendName; 84 | } 85 | 86 | equals(friend: FriendsInfo) { 87 | return ( 88 | this.kakaoUuid === friend.kakaoUuid && 89 | this.kakaoFriendName === friend.kakaoFriendName 90 | ); 91 | } 92 | } 93 | 94 | const exFriend = await this.friendRepository.find({ 95 | where: { userId: user.id }, 96 | select: ['kakaoUuid', 'kakaoFriendName'], 97 | }); 98 | 99 | const exFriendList = exFriend.map((friend) => { 100 | return new FriendsInfo(friend.kakaoUuid, friend.kakaoFriendName); 101 | }); 102 | 103 | const newFriendUuidList = friendsList.filter((friend) => { 104 | const f = new FriendsInfo(friend.uuid, friend.profile_nickname); 105 | return !exFriendList.some((t) => f.equals(t)); 106 | }); 107 | 108 | for await (const element of newFriendUuidList) { 109 | const exFriend = await this.friendRepository.findOne({ 110 | where: { userId: user.id, kakaoUuid: element.uuid }, 111 | }); 112 | if (!exFriend) { 113 | await this.createKakaoFriends(element, user); 114 | } else if (exFriend.kakaoFriendName != element.profile_nickname) { 115 | exFriend.kakaoFriendName = element.profile_nickname; 116 | await this.friendRepository.save(exFriend); 117 | } 118 | } 119 | } 120 | 121 | async createKakaoFriends(element, user: User) { 122 | const friendUserId = await this.findUserByClientId(element.id); 123 | if (friendUserId == null) { 124 | return; 125 | } 126 | const friend = new Friend(); 127 | friend.kakaoUuid = element.uuid; 128 | friend.kakaoFriendName = element.profile_nickname; 129 | friend.friendUser = friendUserId; 130 | friend.user = user; 131 | 132 | return await this.friendRepository.save(friend); 133 | } 134 | 135 | async findUserByClientId(clientId: string): Promise { 136 | const socialUser = await this.userRepository 137 | .createQueryBuilder('user') 138 | .leftJoinAndSelect('user.social', 'social') 139 | .where('social.clientId = :clientId', { clientId: clientId }) 140 | .getOne(); 141 | console.log(socialUser); 142 | 143 | return socialUser; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/domain/kakao/kakao.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { KakaoTokenRepository } from './repository/kakaoToken.memory.repository'; 3 | import { RedisModule } from '@liaoliaots/nestjs-redis'; 4 | import { KakaoService } from './kakao.service'; 5 | 6 | @Module({ 7 | imports: [RedisModule], 8 | controllers: [], 9 | providers: [KakaoTokenRepository, KakaoService], 10 | }) 11 | export class KakaoModule {} 12 | -------------------------------------------------------------------------------- /src/domain/kakao/kakao.service.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import { 3 | InternalServerErrorException, 4 | NotFoundException, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import axios from 'axios'; 8 | import qs from 'qs'; 9 | 10 | export class KakaoService { 11 | authHostUrl = 'https://kauth.kakao.com'; 12 | hostUrl = 'https://kapi.kakao.com'; 13 | 14 | defaultHeaders = { 15 | 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8', 16 | }; 17 | 18 | constructor() {} 19 | 20 | private addAuthHeader(access_token: string) { 21 | return { 22 | ...this.defaultHeaders, 23 | Authorization: `Bearer ${access_token}`, 24 | }; 25 | } 26 | 27 | /* 28 | * Receive access tokens with Kakao login authentication code 29 | * @param code 30 | * @param redirect_uri 31 | * @returns {Promise} 32 | */ 33 | async getKakaoAccessToken(code: string, redirect_uri: string): Promise { 34 | const authEndpoint = '/oauth/token'; 35 | const url = `${this.authHostUrl}${authEndpoint}`; 36 | 37 | const body = { 38 | grant_type: 'authorization_code', 39 | client_id: process.env.KAKAO_CLIENT_ID, 40 | redirect_uri: redirect_uri, 41 | code: code, 42 | }; 43 | 44 | try { 45 | const response = await axios({ 46 | method: 'post', 47 | url: url, 48 | headers: this.defaultHeaders, 49 | data: qs.stringify(body), 50 | }); 51 | 52 | if (response.status == 500) { 53 | throw new InternalServerErrorException({ 54 | message: 'Kakao Internal Server Error', 55 | error: 'Kakao Internal Server Issue Cannot Get Friends', 56 | status: 500, 57 | }); 58 | } 59 | 60 | return response.data; 61 | } catch (e) { 62 | throw new UnauthorizedException({ 63 | message: 'Wrong kakaoAccessCode', 64 | error: "Can't get kakao access token", 65 | statusCode: 401, 66 | }); 67 | } 68 | } 69 | 70 | async refreshKakaoAccessToken(refresh_token: string): Promise { 71 | const authEndpoint = '/oauth/token'; 72 | const url = `${this.authHostUrl}${authEndpoint}`; 73 | 74 | const body = { 75 | grant_type: 'refresh_token', 76 | client_id: process.env.KAKAO_CLIENT_ID, 77 | refresh_token: refresh_token, 78 | }; 79 | 80 | try { 81 | const response = await axios({ 82 | method: 'post', 83 | url: url, 84 | headers: this.defaultHeaders, 85 | data: qs.stringify(body), 86 | }); 87 | 88 | if (response.status == 500) { 89 | throw new InternalServerErrorException({ 90 | message: 'Kakao Internal Server Error', 91 | error: 'Kakao Internal Server Issue Cannot Get Friends', 92 | status: 500, 93 | }); 94 | } 95 | 96 | const { access_token, token_type, expires_in } = response.data; 97 | 98 | return { 99 | access_token, 100 | token_type, 101 | expires_in, 102 | }; 103 | } catch (e) { 104 | throw new UnauthorizedException({ 105 | message: 'Wrong kakaoAccessCode', 106 | error: "Can't refresh kakao access token", 107 | statusCode: 401, 108 | }); 109 | } 110 | } 111 | /* 112 | * Get Kakao Friends and Count 113 | * @param access_token 114 | * @returns {Promise} 115 | */ 116 | async getKakaoFriendsandCount(access_token: string): Promise { 117 | const friendsEndpoint = '/v1/api/talk/friends'; 118 | const url = `${this.hostUrl}${friendsEndpoint}`; 119 | 120 | try { 121 | const response = await axios({ 122 | method: 'get', 123 | url: url, 124 | headers: this.addAuthHeader(access_token), 125 | }); 126 | 127 | if (response.status == 500) { 128 | throw new InternalServerErrorException({ 129 | message: 'Kakao Internal Server Error', 130 | error: 'Kakao Internal Server Issue Cannot Get Friends', 131 | status: 500, 132 | }); 133 | } 134 | 135 | if (response.status == 404) { 136 | return [[], 0]; 137 | } 138 | 139 | return [response.data.elements, response.data.total_count]; 140 | } catch (e) { 141 | throw new UnauthorizedException({ 142 | message: 'Wrong kakaoAccessCode', 143 | error: "Can't get kakao friends", 144 | statusCode: 401, 145 | }); 146 | } 147 | } 148 | 149 | /* 150 | * Get User Kakao Profile 151 | * @param access_token 152 | * @returns {Promise} 153 | * */ 154 | async getKakaoProfile(access_token: string): Promise { 155 | const profileEndpoint = '/v2/user/me'; 156 | const url = `${this.hostUrl}${profileEndpoint}`; 157 | 158 | try { 159 | const response = await axios({ 160 | method: 'get', 161 | url: url, 162 | headers: this.addAuthHeader(access_token), 163 | }); 164 | 165 | if (response.status == 500) { 166 | throw new InternalServerErrorException({ 167 | message: 'Kakao Internal Server Error', 168 | error: 'Kakao Internal Server Issue Cannot Get Friends', 169 | status: 500, 170 | }); 171 | } 172 | 173 | if (response.status == 404) { 174 | throw new UnauthorizedException({ 175 | message: 'Not Registered User', 176 | error: 'Cannot Get Kakao Profile', 177 | status: 404, 178 | }); 179 | } 180 | 181 | return response.data; 182 | } catch (e) { 183 | throw new UnauthorizedException(e, 'Wrong kakaoAccessCode'); 184 | } 185 | } 186 | 187 | /* 188 | * Send Kakao Message 189 | * @param access_token 190 | * @param template_id 191 | * @param template_args 192 | * @returns {Promise} 193 | * */ 194 | async sendKakaoMessage( 195 | access_token: string, 196 | kakao_uuid: string, 197 | template_id: number, 198 | template_args: any, 199 | ): Promise { 200 | const messageEndpoint = '/v1/api/talk/friends/message/send'; 201 | const url = `${this.hostUrl}${messageEndpoint}`; 202 | 203 | const body = { 204 | template_id: template_id, 205 | receiver_uuids: '[' + '"' + kakao_uuid + '"' + ']', 206 | template_args: template_args, 207 | }; 208 | 209 | try { 210 | const response = await axios({ 211 | method: 'post', 212 | url: url, 213 | headers: this.addAuthHeader(access_token), 214 | data: qs.stringify(body), 215 | }); 216 | 217 | if (response.status == 500) { 218 | throw new InternalServerErrorException({ 219 | message: 'Kakao Internal Server Error', 220 | error: 'Kakao Internal Server Issue Cannot Get Friends', 221 | status: 500, 222 | }); 223 | } 224 | 225 | if (response.status == 404) { 226 | throw new NotFoundException({ 227 | message: 'Kakao Friend is not found', 228 | error: 'Cannot Send Kakao Message', 229 | status: 404, 230 | }); 231 | } 232 | 233 | return response.data; 234 | } catch (e) { 235 | throw new UnauthorizedException({ 236 | message: 'Wrong kakaoAccessCode', 237 | error: "Can't get kakao friends", 238 | statusCode: 401, 239 | }); 240 | } 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/domain/kakao/kakaoToken.ts: -------------------------------------------------------------------------------- 1 | export class KakaoToken { 2 | private readonly accessToken: string; 3 | private readonly refreshToken: string; 4 | private readonly accessTokenExpiresIn: number; 5 | private readonly refreshTokenExpiresIn: number; 6 | 7 | constructor( 8 | accessToken: string, 9 | refreshToken: string, 10 | accessTokenExpiresIn: number, 11 | refreshTokenExpiresIn: number, 12 | ) { 13 | this.accessToken = accessToken; 14 | this.refreshToken = refreshToken; 15 | this.accessTokenExpiresIn = accessTokenExpiresIn; 16 | this.refreshTokenExpiresIn = refreshTokenExpiresIn; 17 | } 18 | 19 | getAcessToken(): string { 20 | return this.accessToken; 21 | } 22 | 23 | getExpiresIn(): number { 24 | return this.accessTokenExpiresIn; 25 | } 26 | 27 | getRefreshToken(): string { 28 | return this.refreshToken; 29 | } 30 | 31 | getRefreshTokenExpiresIn(): number { 32 | return this.refreshTokenExpiresIn; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/domain/kakao/repository/kakaoToken.memory.repository.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_REDIS_NAMESPACE, InjectRedis } from '@liaoliaots/nestjs-redis'; 2 | import { KakaoToken } from '../kakaoToken'; 3 | import Redis from 'ioredis'; 4 | import { KakaoService } from '../kakao.service'; 5 | import { InternalServerErrorException } from '@nestjs/common'; 6 | 7 | export class KakaoTokenRepository { 8 | constructor( 9 | @InjectRedis(DEFAULT_REDIS_NAMESPACE) private readonly redis: Redis, 10 | private readonly kakaoService: KakaoService, 11 | ) {} 12 | 13 | async save(userId: number, kakaoToken: KakaoToken): Promise { 14 | const { accessTokenKey, refreshTokenKey } = this.getTokenKey(userId); 15 | 16 | await this.redis.set( 17 | accessTokenKey, 18 | kakaoToken.getAcessToken(), 19 | 'EX', 20 | kakaoToken.getExpiresIn(), 21 | ); 22 | 23 | await this.redis.set( 24 | refreshTokenKey, 25 | kakaoToken.getRefreshToken(), 26 | 'EX', 27 | kakaoToken.getRefreshTokenExpiresIn(), 28 | ); 29 | } 30 | 31 | async findByUserId(userId: number): Promise { 32 | const { accessTokenKey, refreshTokenKey } = this.getTokenKey(userId); 33 | const acessToken = await this.redis.get(accessTokenKey); 34 | const expiresIn = await this.redis.ttl(accessTokenKey); 35 | const refreshToken = await this.redis.get(refreshTokenKey); 36 | const refreshTokenExpiresIn = await this.redis.ttl(refreshTokenKey); 37 | if (acessToken === null) { 38 | try { 39 | const { access_token, expires_in } = 40 | await this.kakaoService.refreshKakaoAccessToken(refreshToken); 41 | await this.save( 42 | userId, 43 | new KakaoToken( 44 | access_token, 45 | refreshToken, 46 | expires_in, 47 | refreshTokenExpiresIn, 48 | ), 49 | ); 50 | } catch (e) { 51 | throw new InternalServerErrorException({ 52 | message: 'Cannot refresh Kakao access token', 53 | error: 'KAKAO_REFRESH_TOKEN_ERROR', 54 | }); 55 | } 56 | } 57 | 58 | return new KakaoToken( 59 | acessToken, 60 | refreshToken, 61 | expiresIn, 62 | refreshTokenExpiresIn, 63 | ); 64 | } 65 | 66 | private getTokenKey(userId: number): { 67 | accessTokenKey: string; 68 | refreshTokenKey: string; 69 | } { 70 | return { 71 | accessTokenKey: `${userId.toString()}:accessToken`, 72 | refreshTokenKey: `${userId.toString()}:refreshToken`, 73 | }; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/domain/letter/dto/requests/createDraftLetter.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber, IsOptional, IsString } from 'class-validator'; 3 | 4 | export class CreateDraftLetterDto { 5 | @IsNumber() 6 | @IsOptional() 7 | @ApiProperty({ 8 | example: 1, 9 | description: 10 | '유저 id : 받는 유저가 비회원일 경우 입력 X, 받는 유저가 회원일 경우 입력', 11 | }) 12 | readonly receiverId?: number; 13 | 14 | @IsString() 15 | @IsOptional() 16 | @ApiProperty({ 17 | example: 'Pretty Minusu', 18 | description: '받는 사람 닉네임: 받는 유저가 회원이 아닐 경우 입력', 19 | }) 20 | readonly receiverNickname?: string; 21 | 22 | @IsNumber() 23 | @ApiProperty({ 24 | example: 1, 25 | description: '상황 id', 26 | }) 27 | readonly situationId: number; 28 | 29 | @IsString() 30 | @ApiProperty({ 31 | example: '생일 축하해', 32 | description: '편지 제목', 33 | }) 34 | readonly title: string; 35 | 36 | @IsString() 37 | @ApiProperty({ 38 | example: '생일 축하하고 싶어서 편지를 써봤어', 39 | description: '편지 내용', 40 | }) 41 | readonly content: string; 42 | } 43 | -------------------------------------------------------------------------------- /src/domain/letter/dto/requests/createExternalLetter.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsDateString, IsNumber, IsString } from 'class-validator'; 3 | 4 | export class CreateExternalTextLetterDto { 5 | @IsString() 6 | @ApiProperty({ 7 | example: '행복한 크리스마스 보내', 8 | description: '편지 제목', 9 | }) 10 | readonly title: string; 11 | 12 | @IsString() 13 | @ApiProperty({ 14 | example: '솔로지만 크리스마스 잘 보내고', 15 | description: '편지 내용', 16 | }) 17 | readonly content: string; 18 | 19 | @IsDateString() 20 | @ApiProperty({ 21 | example: '2022-12-25 00:00:00', 22 | description: '편지 받은 날짜', 23 | }) 24 | readonly receivedAt: Date; 25 | 26 | @IsString() 27 | @ApiProperty({ 28 | example: 'Pretty Minusu', 29 | description: '받는 사람 닉네임: 받는 유저가 회원이 아닐 경우 입력', 30 | }) 31 | readonly senderNickname: string; 32 | 33 | @IsNumber() 34 | @ApiProperty({ 35 | example: 1, 36 | description: '상황 id', 37 | }) 38 | readonly situationId: number; 39 | } 40 | -------------------------------------------------------------------------------- /src/domain/letter/dto/requests/createExternalLetterImg.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsDateString, IsNumber, IsString, IsUrl } from 'class-validator'; 3 | 4 | export class CreateExternalImgLetterDto { 5 | @IsString() 6 | @ApiProperty({ 7 | example: '행복한 크리스마스 보내', 8 | description: '편지 제목', 9 | }) 10 | readonly title: string; 11 | 12 | @IsUrl() 13 | @ApiProperty({ 14 | example: 'https://s3.ggo-geet.com/letter/1.png', 15 | description: '편지 이미지 url', 16 | }) 17 | readonly imageUrl: string; 18 | 19 | @IsDateString() 20 | @ApiProperty({ 21 | example: '2022-12-25 00:00:00', 22 | description: '편지 받은 날짜', 23 | }) 24 | readonly receivedAt: Date; 25 | 26 | @IsString() 27 | @ApiProperty({ 28 | example: 'Pretty Minusu', 29 | description: '받는 사람 닉네임: 받는 유저가 회원이 아닐 경우 입력', 30 | }) 31 | readonly senderNickname: string; 32 | 33 | @IsNumber() 34 | @ApiProperty({ 35 | example: 1, 36 | description: '상황 id', 37 | }) 38 | readonly situationId: number; 39 | } 40 | -------------------------------------------------------------------------------- /src/domain/letter/dto/requests/findAllLetter.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiPropertyOptional } from '@nestjs/swagger'; 2 | import { IsDateString, IsOptional } from 'class-validator'; 3 | import { PaginationRequest } from 'src/common/paginations/pagination.request'; 4 | 5 | export class findAllReceviedLetterDto extends PaginationRequest { 6 | @ApiPropertyOptional({ 7 | description: '보낸 사람 ID 리스트', 8 | example: ['1', '2'], 9 | }) 10 | @IsOptional() 11 | readonly senders: string[]; 12 | 13 | @ApiPropertyOptional({ 14 | description: '검색할 상황 ID 리스트', 15 | example: ['1', '2'], 16 | }) 17 | @IsOptional() 18 | readonly situations: string[]; 19 | 20 | @ApiPropertyOptional({ 21 | example: '2022-01-01 00:00:00', 22 | description: '편지 검색 시작 날짜', 23 | }) 24 | @IsOptional() 25 | @IsDateString() 26 | readonly startDate: Date; 27 | 28 | @ApiPropertyOptional({ 29 | example: '2023-12-25 00:00:00', 30 | description: '편지 검색 시작 날짜', 31 | }) 32 | @IsOptional() 33 | @IsDateString() 34 | readonly endDate: Date; 35 | 36 | @ApiPropertyOptional({ 37 | description: '날짜순 정렬', 38 | example: 'ASC', 39 | }) 40 | @IsOptional() 41 | readonly order: string; 42 | } 43 | 44 | export class findAllSentLetterDto extends PaginationRequest { 45 | @ApiPropertyOptional({ 46 | description: '받는 사람 ID 리스트', 47 | example: ['1', '2'], 48 | }) 49 | @IsOptional() 50 | readonly receivers: string[]; 51 | 52 | @ApiPropertyOptional({ 53 | description: '검색할 상황 ID 리스트', 54 | example: ['1', '2'], 55 | }) 56 | @IsOptional() 57 | readonly situations: string[]; 58 | 59 | @ApiPropertyOptional({ 60 | example: '2022-01-01 00:00:00', 61 | description: '편지 검색 시작 날짜', 62 | }) 63 | @IsOptional() 64 | @IsDateString() 65 | readonly startDate: Date; 66 | 67 | @ApiPropertyOptional({ 68 | example: '2023-12-25 00:00:00', 69 | description: '편지 검색 시작 날짜', 70 | }) 71 | @IsOptional() 72 | @IsDateString() 73 | readonly endDate: Date; 74 | 75 | @ApiPropertyOptional({ 76 | description: '날짜순 정렬', 77 | example: 'ASC', 78 | }) 79 | @IsOptional() 80 | readonly order: string; 81 | } 82 | -------------------------------------------------------------------------------- /src/domain/letter/dto/requests/kakaoCallback.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumberString, IsString } from 'class-validator'; 2 | 3 | export class KakaoMessageCallbackDto { 4 | @IsString() 5 | CHAT_TYPE: string; 6 | 7 | @IsString() 8 | HASH_CHAT_ID: string; 9 | 10 | @IsNumberString() 11 | TEMPLATE_ID: string; 12 | 13 | @IsNumberString() 14 | TEMP_LETTER_ID: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/domain/letter/dto/responses/letterDetail.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ReceivedLetter } from 'src/domain/letter/entities/receivedLetter.entity'; 3 | import { SendLetter } from 'src/domain/letter/entities/sendLetter.entity'; 4 | 5 | export class SendLetterDetailResponseDto { 6 | @ApiProperty({ example: 1, description: '보낸편지 id' }) 7 | id: number; 8 | 9 | @ApiProperty({ example: '받는이', description: '받는사람 이름' }) 10 | receiverNickname: string; 11 | 12 | @ApiProperty({ example: '2021-01-01 00:00:00', description: '언제 보냈는지' }) 13 | sendAt: Date; 14 | 15 | @ApiProperty({ example: '받는이야 생일축하해~', description: '내용' }) 16 | content: string; 17 | 18 | @ApiProperty({ example: 1, description: '상황 id' }) 19 | situationId: number; 20 | 21 | constructor(letter: SendLetter) { 22 | this.id = letter.id; 23 | this.receiverNickname = letter.receiverNickname; 24 | this.sendAt = letter.sendAt; 25 | this.situationId = letter.letterBody.situationId; 26 | this.content = letter.letterBody.content; 27 | } 28 | } 29 | 30 | export class ReceivedLetterDetailResponseDto { 31 | @ApiProperty({ example: 1, description: '받은 편지 id' }) 32 | id: number; 33 | 34 | @ApiProperty({ example: '보낸이', description: '보낸사람 이름' }) 35 | senderNickname: string; 36 | 37 | @ApiProperty({ example: '2021-01-01 00:00:00', description: '언제 받았는지' }) 38 | receivedAt: Date; 39 | 40 | @ApiProperty({ example: 'text OR image', description: '외부편지 추가 형식' }) 41 | type: string; 42 | 43 | @ApiProperty({ 44 | example: '파일 경로', 45 | description: '외부 이미지 편지의 이미지 주소', 46 | }) 47 | imageContent: string; 48 | 49 | @ApiProperty({ example: '받는이야 생일축하해~', description: '내용' }) 50 | content: string; 51 | 52 | @ApiProperty({ example: 1, description: '상황 id' }) 53 | situationId: number; 54 | 55 | constructor(letter: ReceivedLetter) { 56 | this.id = letter.id; 57 | this.senderNickname = letter.senderNickname; 58 | this.receivedAt = letter.receivedAt; 59 | this.situationId = letter.letterBody.situationId; 60 | this.content = letter.letterBody.content; 61 | this.type = letter.letterBody.type; 62 | this.imageContent = letter.letterBody.imageContent; 63 | } 64 | } 65 | 66 | export class tempLetterResponseDto { 67 | @ApiProperty({ 68 | example: 1, 69 | description: '보낸 편지 id (상대방이 보낼 때 저장하기 때문에)', 70 | }) 71 | id: number; 72 | 73 | @ApiProperty({ example: '보낸이', description: '보낸사람 이름' }) 74 | senderNickname: string; 75 | 76 | @ApiProperty({ example: '2021-01-01 00:00:00', description: '언제 받았는지' }) 77 | receivedAt: Date; 78 | 79 | @ApiProperty({ example: '받는이야 생일축하해~', description: '내용' }) 80 | content: string; 81 | 82 | @ApiProperty({ example: 1, description: '상황 id' }) 83 | situationId: number; 84 | 85 | constructor(letter: SendLetter) { 86 | this.id = letter.id; 87 | this.senderNickname = letter.sender.nickname; 88 | this.receivedAt = letter.sendAt; 89 | this.situationId = letter.letterBody.situationId; 90 | this.content = letter.letterBody.content; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/domain/letter/dto/responses/letterStorage.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { ReceivedLetter } from 'src/domain/letter/entities/receivedLetter.entity'; 3 | import { SendLetter } from 'src/domain/letter/entities/sendLetter.entity'; 4 | 5 | export class ReceviedAllResponseDto { 6 | @ApiProperty({ example: 1, description: '받은 편지 id' }) 7 | id: number; 8 | @ApiProperty({ example: '보낸이', description: '보낸사람 이름' }) 9 | senderNickname: string; 10 | @ApiProperty({ example: '2021-01-01 00:00:00', description: '언제 받았는지' }) 11 | receivedAt: Date; 12 | @ApiProperty({ example: '생축', description: '편지 제목' }) 13 | title: string; 14 | @ApiProperty({ example: 1, description: '상황 id' }) 15 | situationId: number; 16 | 17 | constructor(letter: ReceivedLetter) { 18 | this.id = letter.id; 19 | this.senderNickname = letter.senderNickname; 20 | this.receivedAt = letter.receivedAt; 21 | this.situationId = letter.letterBody.situationId; 22 | this.title = letter.letterBody.title; 23 | } 24 | } 25 | 26 | export class SendAllResponseDto { 27 | @ApiProperty({ example: 1, description: '보낸편지 id' }) 28 | id: number; 29 | @ApiProperty({ example: '받는이', description: '받는사람 이름' }) 30 | receiverNickname: string; 31 | 32 | @ApiProperty({ example: '2021-01-01 00:00:00', description: '언제 보냈는지' }) 33 | sendAt: Date; 34 | @ApiProperty({ example: '생축', description: '편지 제목' }) 35 | title: string; 36 | 37 | @ApiProperty({ example: 1, description: '상황 id' }) 38 | situationId: number; 39 | 40 | constructor(letter: SendLetter) { 41 | this.id = letter.id; 42 | this.receiverNickname = letter.receiverNickname; 43 | this.sendAt = letter.sendAt; 44 | this.situationId = letter.letterBody.situationId; 45 | this.title = letter.letterBody.title; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/domain/letter/dto/responses/receviedTempLetter.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ReceviedTempLetterResponseDto { 4 | @ApiProperty({ 5 | example: 1, 6 | description: '편지 ID', 7 | }) 8 | id: number; 9 | 10 | @ApiProperty({ 11 | example: 'Pretty Minusu', 12 | description: '보낸 사람 닉네임', 13 | }) 14 | senderNickname: string; 15 | 16 | @ApiProperty({ 17 | example: '2022-12-25 00:00:00', 18 | description: '받은 날짜', 19 | }) 20 | receivedAt: Date; 21 | 22 | @ApiProperty({ 23 | example: '크리스마스 축하해', 24 | description: '편지 내용', 25 | }) 26 | content: string; 27 | 28 | @ApiProperty({ 29 | example: 1, 30 | description: '상황 ID', 31 | }) 32 | situationId: number; 33 | } 34 | -------------------------------------------------------------------------------- /src/domain/letter/entities/letterBody.entity.ts: -------------------------------------------------------------------------------- 1 | import { Reply } from '../../reply/entities/reply.entity'; 2 | import { 3 | Column, 4 | Entity, 5 | JoinColumn, 6 | OneToOne, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | import { LetterType } from '../letter.constants'; 10 | 11 | @Entity('letterBody') 12 | export class LetterBody { 13 | @PrimaryGeneratedColumn('increment') 14 | id: number; 15 | 16 | @Column({ type: 'text', nullable: true }) 17 | title: string; 18 | 19 | @Column({ type: 'text', nullable: true }) 20 | content: string; 21 | 22 | @Column({ type: 'varchar', length: 255, nullable: true }) 23 | resultImg: string; 24 | 25 | @Column({ type: 'varchar', length: 255, nullable: false }) 26 | accessCode: string; 27 | 28 | @Column({ type: 'varchar', length: 255, nullable: true }) 29 | imageContent: string; 30 | 31 | @Column({ 32 | type: 'enum', 33 | enum: LetterType, 34 | }) 35 | type: LetterType; 36 | 37 | @OneToOne(() => Reply, { cascade: true }) 38 | @JoinColumn() 39 | reply: Reply; 40 | 41 | @Column({ type: 'int' }) 42 | situationId: number; 43 | } 44 | -------------------------------------------------------------------------------- /src/domain/letter/entities/receivedLetter.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | JoinColumn, 7 | ManyToOne, 8 | OneToOne, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | import { User } from '../../users/entities/user.entity'; 13 | import { LetterBody } from './letterBody.entity'; 14 | 15 | @Entity('receivedLetter') 16 | export class ReceivedLetter { 17 | @PrimaryGeneratedColumn('increment') 18 | id: number; 19 | 20 | @ManyToOne(() => User) 21 | @JoinColumn({ 22 | name: 'receiverId', 23 | referencedColumnName: 'id', 24 | }) 25 | receiver: User; 26 | 27 | @ManyToOne(() => User) 28 | @JoinColumn({ 29 | name: 'senderId', 30 | referencedColumnName: 'id', 31 | }) 32 | sender: User; 33 | 34 | @OneToOne(() => LetterBody, { cascade: true }) 35 | @JoinColumn({ 36 | name: 'letterBodyId', 37 | referencedColumnName: 'id', 38 | }) 39 | letterBody: LetterBody; 40 | 41 | @Column({ type: 'varchar', length: '255' }) 42 | senderNickname: string; 43 | 44 | @CreateDateColumn() 45 | receivedAt: Date; 46 | 47 | @CreateDateColumn() 48 | createdAt: Date; 49 | 50 | @UpdateDateColumn() 51 | updatedAt: Date; 52 | 53 | @DeleteDateColumn() 54 | deletedAt: Date; 55 | } 56 | -------------------------------------------------------------------------------- /src/domain/letter/entities/sendLetter.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | JoinColumn, 7 | ManyToOne, 8 | OneToOne, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | import { User } from '../../users/entities/user.entity'; 13 | import { SendLetterStatus } from '../letter.constants'; 14 | import { LetterBody } from './letterBody.entity'; 15 | 16 | @Entity('sendLetter') 17 | export class SendLetter { 18 | @PrimaryGeneratedColumn('increment') 19 | id: number; 20 | 21 | @ManyToOne(() => User) 22 | @JoinColumn({ 23 | name: 'senderId', 24 | referencedColumnName: 'id', 25 | }) 26 | sender: User; 27 | 28 | @ManyToOne(() => User) 29 | @JoinColumn({ 30 | name: 'receiverId', 31 | referencedColumnName: 'id', 32 | }) 33 | receiver: User; 34 | 35 | @Column({ 36 | type: 'enum', 37 | enum: SendLetterStatus, 38 | }) 39 | status: string; 40 | 41 | @Column({ type: 'varchar', length: '255' }) 42 | receiverNickname: string; 43 | 44 | @OneToOne(() => LetterBody, { cascade: true }) 45 | @JoinColumn() 46 | letterBody: LetterBody; 47 | 48 | @CreateDateColumn() 49 | sendAt: Date; 50 | 51 | @CreateDateColumn() 52 | createdAt: Date; 53 | 54 | @UpdateDateColumn() 55 | updatedAt: Date; 56 | 57 | @DeleteDateColumn() 58 | deletedAt: Date; 59 | } 60 | -------------------------------------------------------------------------------- /src/domain/letter/letter.constants.ts: -------------------------------------------------------------------------------- 1 | export enum LetterType { 2 | EXTERNAL = 'text', 3 | EXTERNALIMG = 'image', 4 | INTERNAL = 'received', 5 | } 6 | 7 | export enum SendLetterStatus { 8 | TMP_SAVED = 'tmpSaved', 9 | SENT = 'sent', 10 | } 11 | -------------------------------------------------------------------------------- /src/domain/letter/letter.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | Param, 8 | Post, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { ReqUser } from 'src/common/decorators/user.decorators'; 13 | import { JwtAuthGuard } from 'src/common/guards/jwtAuth.guard'; 14 | import { User } from 'src/domain/users/entities/user.entity'; 15 | import { LetterService } from './letter.service'; 16 | import { 17 | ApiBearerAuth, 18 | ApiOperation, 19 | ApiResponse, 20 | ApiTags, 21 | } from '@nestjs/swagger'; 22 | import { CreateDraftLetterDto } from './dto/requests/createDraftLetter.request.dto'; 23 | import { KakaoMessageCallbackDto } from './dto/requests/kakaoCallback.request.dto'; 24 | 25 | @Controller('letters') 26 | export class LetterController { 27 | constructor(private readonly letterService: LetterService) {} 28 | 29 | @ApiTags('Letter API') 30 | @ApiOperation({ 31 | summary: '편지 생성 API', 32 | description: 33 | '신규 편지를 생성합니다, 편지를 보내기 위해서는 Complete API를 호출해주세요.', 34 | }) 35 | @UseGuards(JwtAuthGuard) 36 | @ApiBearerAuth() 37 | @Post('/draft') 38 | @HttpCode(HttpStatus.CREATED) 39 | async createDraftLetter( 40 | @ReqUser() user: User, 41 | @Body() createSendLetterDto: CreateDraftLetterDto, 42 | ) { 43 | const result = await this.letterService.createDraftLetter( 44 | user, 45 | createSendLetterDto, 46 | ); 47 | return { data: result }; 48 | } 49 | 50 | @ApiTags('Letter API') 51 | @ApiOperation({ 52 | summary: '편지 전송 API', 53 | description: '편지를 친구에게 보냅니다.', 54 | }) 55 | @UseGuards(JwtAuthGuard) 56 | @ApiBearerAuth() 57 | @Post(':id/complete') 58 | @HttpCode(HttpStatus.CREATED) 59 | async sendLetter(@ReqUser() user: User, @Param('id') id: number) { 60 | const letter = await this.letterService.sendLetter(user, id); 61 | return { data: letter }; 62 | } 63 | 64 | @ApiTags('Letter API') 65 | @ApiOperation({ 66 | summary: '편지 외부 전송 API', 67 | description: '편지를 친구가 아닌 외부에 보냅니다.', 68 | }) 69 | @ApiResponse({ 70 | status: 201, 71 | description: '임시 편지가 성공적으로 생성되었습니다.', 72 | }) 73 | @UseGuards(JwtAuthGuard) 74 | @ApiBearerAuth() 75 | @Post(':id/temp-complete') 76 | @HttpCode(HttpStatus.CREATED) 77 | async sendTempLetter(@ReqUser() user: User, @Param('id') id: number) { 78 | return this.letterService.sendTempLetter(user, id); 79 | } 80 | 81 | @ApiTags('Letter API') 82 | @ApiOperation({ 83 | summary: '편지 외부 전송 콜백 API', 84 | description: '카카오로 전송된 편지의 Callback을 받습니다.', 85 | }) 86 | @Get('/temp-complete/kakao/callback') 87 | @HttpCode(HttpStatus.CREATED) 88 | async getKaKaoTempLetterCallback(@Query() query: KakaoMessageCallbackDto) { 89 | return this.letterService.getKaKaoTempLetterCallback(query); 90 | } 91 | 92 | @ApiTags('Letter API') 93 | @ApiOperation({ 94 | summary: '편지 외부 전송 콜백 확인 API', 95 | description: '카카오로 전송된 편지의 Callback을 확인합니다.', 96 | }) 97 | @UseGuards(JwtAuthGuard) 98 | @ApiBearerAuth() 99 | @Get(':id/temp-complete/kakao/callback/confirm') 100 | async getKaKaoTempLetterCallbackCheck(@Param('id') id: number) { 101 | return this.letterService.getKaKaoTempLetterCallbackCheck(id); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/domain/letter/letter.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { MulterModule } from '@nestjs/platform-express'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { User } from 'src/domain/users/entities/user.entity'; 6 | import { multerAttachedImgOptionsFactory } from 'src/infrastructor/S3/s3.multer'; 7 | import { ReceivedLetter } from './entities/receivedLetter.entity'; 8 | import { LetterController } from './letter.controller'; 9 | import { LetterService } from './letter.service'; 10 | import { PassportModule } from '@nestjs/passport'; 11 | import { SendLetter } from './entities/sendLetter.entity'; 12 | import { AuthModule } from 'src/auth/auth.module'; 13 | import { JwtStrategy } from 'src/auth/strategy/jwt.strategy'; 14 | import { LetterSentService } from './letter.sent.service'; 15 | import { LetterReceivedService } from './letter.received.service'; 16 | import { Friend } from 'src/domain/friend/entities/friend.entity'; 17 | import { KakaoService } from 'src/domain/kakao/kakao.service'; 18 | import { KakaoTokenRepository } from 'src/domain/kakao/repository/kakaoToken.memory.repository'; 19 | import { RedisModule } from '@liaoliaots/nestjs-redis'; 20 | import { TempLetterRepository } from './repository/tempLetter.repository'; 21 | 22 | @Module({ 23 | imports: [ 24 | MulterModule.registerAsync({ 25 | imports: [ConfigModule], 26 | useFactory: multerAttachedImgOptionsFactory, 27 | inject: [ConfigService], 28 | }), 29 | TypeOrmModule.forFeature([ReceivedLetter, User, SendLetter, Friend]), 30 | PassportModule, 31 | AuthModule, 32 | RedisModule, 33 | ], 34 | controllers: [LetterController], 35 | providers: [ 36 | LetterService, 37 | LetterSentService, 38 | LetterReceivedService, 39 | JwtStrategy, 40 | KakaoService, 41 | KakaoTokenRepository, 42 | TempLetterRepository, 43 | ], 44 | }) 45 | export class LetterModule {} 46 | -------------------------------------------------------------------------------- /src/domain/letter/letter.received.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException, 5 | } from '@nestjs/common'; 6 | import { Repository } from 'typeorm'; 7 | import { ReceivedLetter } from './entities/receivedLetter.entity'; 8 | import { InjectRepository } from '@nestjs/typeorm'; 9 | import { User } from 'src/domain/users/entities/user.entity'; 10 | import { CreateExternalImgLetterDto } from './dto/requests/createExternalLetterImg.request.dto'; 11 | import { CreateExternalTextLetterDto } from './dto/requests/createExternalLetter.request.dto'; 12 | import { LetterBody } from './entities/letterBody.entity'; 13 | import { LetterType, SendLetterStatus } from './letter.constants'; 14 | import { PaginationBuilder } from 'src/common/paginations/paginationBuilder.response'; 15 | import { TempLetterRepository } from './repository/tempLetter.repository'; 16 | import { ReceviedTempLetterResponseDto } from './dto/responses/receviedTempLetter.response.dto'; 17 | import { ReceviedAllResponseDto } from './dto/responses/letterStorage.response.dto'; 18 | import { ReceivedLetterDetailResponseDto } from './dto/responses/letterDetail.response.dto'; 19 | import { SendLetter } from './entities/sendLetter.entity'; 20 | 21 | @Injectable() 22 | export class LetterReceivedService { 23 | constructor( 24 | @InjectRepository(ReceivedLetter) 25 | private receivedLetterRepository: Repository, 26 | private tempLetterRepository: TempLetterRepository, 27 | @InjectRepository(SendLetter) 28 | private sendLetterRepository: Repository, 29 | ) {} 30 | 31 | async createTextLetter( 32 | user: User, 33 | createExternalTextLetter: CreateExternalTextLetterDto, 34 | ) { 35 | const letterBody = new LetterBody(); 36 | letterBody.title = createExternalTextLetter.title; 37 | letterBody.content = createExternalTextLetter.content; 38 | letterBody.type = LetterType.EXTERNAL; 39 | letterBody.situationId = createExternalTextLetter.situationId; 40 | 41 | const receivedLetter = new ReceivedLetter(); 42 | receivedLetter.receiver = user; 43 | receivedLetter.letterBody = letterBody; 44 | receivedLetter.senderNickname = createExternalTextLetter.senderNickname; 45 | 46 | const result = await this.receivedLetterRepository.save(receivedLetter); 47 | return result; 48 | } 49 | 50 | async createImageLetter( 51 | user: User, 52 | createExternalImgLetterDto: CreateExternalImgLetterDto, 53 | ) { 54 | const letterBody = new LetterBody(); 55 | letterBody.title = createExternalImgLetterDto.title; 56 | letterBody.imageContent = createExternalImgLetterDto.imageUrl; 57 | letterBody.type = LetterType.EXTERNALIMG; 58 | letterBody.situationId = createExternalImgLetterDto.situationId; 59 | 60 | const receivedLetter = new ReceivedLetter(); 61 | receivedLetter.receiver = user; 62 | receivedLetter.letterBody = letterBody; 63 | receivedLetter.senderNickname = createExternalImgLetterDto.senderNickname; 64 | 65 | const result = await this.receivedLetterRepository.save(receivedLetter); 66 | return result; 67 | } 68 | 69 | async findAll(user: User, query: any): Promise { 70 | const order = query.order === 'ASC' ? 'ASC' : 'DESC'; 71 | 72 | const letter = this.receivedLetterRepository 73 | .createQueryBuilder('receivedLetter') 74 | .leftJoinAndSelect('receivedLetter.letterBody', 'letterBody') 75 | .where('receiverId = :id', { id: user.id }); 76 | 77 | if (query.startDate !== undefined && query.endDate !== undefined) { 78 | letter.andWhere('receivedAt BETWEEN :startDate AND :endDate', { 79 | startDate: query.startDate, 80 | endDate: query.endDate, 81 | }); 82 | } 83 | 84 | if (query.senders !== undefined) { 85 | letter.andWhere('senderId IN (:senders)', { 86 | senders: query.senders, 87 | }); 88 | } 89 | 90 | if (query.situations != undefined) { 91 | letter.andWhere('situationId IN (:situations)', { 92 | situations: query.situations, 93 | }); 94 | } 95 | 96 | const [dataList, count] = await letter 97 | 98 | .skip(query.getSkip()) 99 | .take(query.getTake()) 100 | .orderBy('receivedLetter.receivedAt', order) 101 | .getManyAndCount(); 102 | console.log(dataList); 103 | const data = dataList.map((element) => { 104 | return new ReceviedAllResponseDto(element); 105 | }); 106 | return new PaginationBuilder() 107 | .setData(data) 108 | .setTotalCount(count) 109 | .setPage(query.getPage()) 110 | .setTake(query.getTake()) 111 | .build(); 112 | } 113 | 114 | async findOne( 115 | user: User, 116 | id: number, 117 | ): Promise { 118 | const letter = await this.receivedLetterRepository.findOne({ 119 | where: { id: id, receiver: { id: user.id } }, 120 | relations: ['letterBody'], 121 | }); 122 | if (!letter) { 123 | throw new NotFoundException({ 124 | statusCode: 404, 125 | message: 'This Letter is not available', 126 | error: 127 | "Bad Request to this Letter Id OR You don't have access to this letter.", 128 | }); 129 | } 130 | 131 | return new ReceivedLetterDetailResponseDto(letter); 132 | } 133 | 134 | async delete(id: number): Promise { 135 | const result = await this.receivedLetterRepository.softDelete(id); 136 | if (result.affected === 0) { 137 | throw new NotFoundException({ 138 | statusCode: 404, 139 | message: 'This Letter is not available', 140 | error: 141 | "Bad Request to this Letter Id OR You don't have access to this letter.", 142 | }); 143 | } 144 | } 145 | 146 | async uploadExternalLetterImage(file: Express.MulterS3.File): Promise { 147 | if (!file) { 148 | throw new NotFoundException({ 149 | statusCode: 404, 150 | message: 'There is no image file', 151 | error: 'Image file is not uploaded', 152 | }); 153 | } 154 | return { 155 | filePath: file.location, 156 | }; 157 | } 158 | 159 | async findOneTemp(id: number): Promise { 160 | const isActive = this.tempLetterRepository.findById(id); 161 | 162 | if (!isActive) { 163 | throw new BadRequestException({ 164 | message: 'Letter is Already Time Out or Deleted', 165 | error: 166 | 'Your letter access time has expired and you can no longer read it', 167 | }); 168 | } 169 | 170 | const sendLetter = await this.sendLetterRepository.findOne({ 171 | where: { id: id }, 172 | relations: { 173 | receiver: true, 174 | letterBody: true, 175 | sender: true, 176 | }, 177 | }); 178 | 179 | if (!sendLetter || sendLetter.status == SendLetterStatus.SENT) { 180 | throw new NotFoundException({ 181 | message: 'There is no id', 182 | error: 'Bad Request to this Id, There is no id', 183 | }); 184 | } 185 | 186 | const result = new ReceviedTempLetterResponseDto(); 187 | result.id = id; 188 | result.senderNickname = sendLetter.sender.nickname; 189 | result.receivedAt = sendLetter.createdAt; 190 | result.content = sendLetter.letterBody.content; 191 | result.situationId = sendLetter.letterBody.situationId; 192 | 193 | return result; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/domain/letter/letter.sent.controller.ts: -------------------------------------------------------------------------------- 1 | import { LetterSentService } from './letter.sent.service'; 2 | import { 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { 13 | ApiBearerAuth, 14 | ApiOperation, 15 | ApiResponse, 16 | ApiTags, 17 | } from '@nestjs/swagger'; 18 | import { 19 | ApiPaginationRequst, 20 | ApiPaginationResponse, 21 | } from '../../common/paginations/pagination.swagger'; 22 | import { SendAllResponseDto } from './dto/responses/letterStorage.response.dto'; 23 | import { JwtAuthGuard } from '../../common/guards/jwtAuth.guard'; 24 | import { ReqUser } from '../../common/decorators/user.decorators'; 25 | import { User } from '../users/entities/user.entity'; 26 | import { findAllSentLetterDto } from './dto/requests/findAllLetter.request.dto'; 27 | import { SendLetterDetailResponseDto } from './dto/responses/letterDetail.response.dto'; 28 | 29 | @Controller('letters/sent') 30 | export class LetterSentController { 31 | constructor(private readonly letterSentService: LetterSentService) {} 32 | 33 | @ApiTags('Sent Letter API') 34 | @ApiOperation({ 35 | summary: '보낸 편지함 조회 API', 36 | description: '보낸 편지를 조회합니다.', 37 | }) 38 | @ApiPaginationRequst() 39 | @ApiPaginationResponse(SendAllResponseDto) 40 | @UseGuards(JwtAuthGuard) 41 | @ApiBearerAuth() 42 | @HttpCode(HttpStatus.OK) 43 | @Get() 44 | async getSendLetter( 45 | @ReqUser() user: User, 46 | @Query() query: findAllSentLetterDto, 47 | ) { 48 | return this.letterSentService.findAll(user, query); 49 | } 50 | 51 | @ApiTags('Sent Letter API') 52 | @ApiOperation({ 53 | summary: '보낸 편지 상세 조회 API', 54 | description: '보낸 편지를 상세 조회합니다.', 55 | }) 56 | @ApiResponse({ 57 | status: HttpStatus.OK, 58 | description: '보낸 편지', 59 | type: SendLetterDetailResponseDto, 60 | }) 61 | @UseGuards(JwtAuthGuard) 62 | @ApiBearerAuth() 63 | @Get('/:id') 64 | async getSentLetter(@ReqUser() user: User, @Param('id') id: number) { 65 | const sentLetter = await this.letterSentService.findOne(user, id); 66 | return { data: sentLetter }; 67 | } 68 | 69 | @ApiTags('Sent Letter API') 70 | @ApiOperation({ 71 | summary: '보낸 편지 삭제하기 API', 72 | description: '보낸 편지를 삭제합니다.', 73 | }) 74 | @UseGuards(JwtAuthGuard) 75 | @ApiBearerAuth() 76 | @Delete('/:id') 77 | @HttpCode(HttpStatus.NO_CONTENT) 78 | deleteSentLetter(@ReqUser() user: User, @Param('id') id: number) { 79 | return this.letterSentService.delete(user, id); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/domain/letter/letter.sent.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { User } from 'src/domain/users/entities/user.entity'; 4 | import { SendLetter } from './entities/sendLetter.entity'; 5 | import { Repository } from 'typeorm'; 6 | import { findAllSentLetterDto } from './dto/requests/findAllLetter.request.dto'; 7 | import { PaginationBuilder } from 'src/common/paginations/paginationBuilder.response'; 8 | import { SendAllResponseDto } from './dto/responses/letterStorage.response.dto'; 9 | import { SendLetterDetailResponseDto } from './dto/responses/letterDetail.response.dto'; 10 | 11 | @Injectable() 12 | export class LetterSentService { 13 | constructor( 14 | @InjectRepository(SendLetter) 15 | private sendLetterRepository: Repository, 16 | ) {} 17 | 18 | async findAll( 19 | user: User, 20 | query: findAllSentLetterDto, 21 | ): Promise { 22 | const order = query.order === 'ASC' ? 'ASC' : 'DESC'; 23 | 24 | const letter = this.sendLetterRepository 25 | .createQueryBuilder('sendLetter') 26 | .where('senderId = :id', { id: user.id }); 27 | 28 | if (query.startDate !== undefined && query.endDate !== undefined) { 29 | letter.andWhere('sendAt BETWEEN :startDate AND :endDate', { 30 | startDate: query.startDate, 31 | endDate: query.endDate, 32 | }); 33 | } 34 | 35 | if (query.receivers !== undefined) { 36 | letter.andWhere('receiverId IN (:receivers)', { 37 | receivers: query.receivers, 38 | }); 39 | } 40 | 41 | if (query.situations != undefined) { 42 | letter.andWhere('situationId IN (:situations)', { 43 | situations: query.situations, 44 | }); 45 | } 46 | 47 | const [dataList, count] = await letter 48 | .leftJoinAndSelect('sendLetter.letterBody', 'letterBody') 49 | .skip(query.getSkip()) 50 | .take(query.getTake()) 51 | .orderBy('sendLetter.sendAt', order) 52 | .getManyAndCount(); 53 | 54 | const data = dataList.map((element) => { 55 | return new SendAllResponseDto(element); 56 | }); 57 | 58 | return new PaginationBuilder() 59 | .setData(data) 60 | .setTotalCount(count) 61 | .setPage(query.getPage()) 62 | .setTake(query.getTake()) 63 | .build(); 64 | } 65 | 66 | async findOne(user: User, id: number): Promise { 67 | const letter = await this.sendLetterRepository.findOne({ 68 | where: { id: id, sender: { id: user.id } }, 69 | relations: ['letterBody'], 70 | }); 71 | if (!letter) { 72 | throw new NotFoundException({ 73 | statusCode: 404, 74 | message: 'This Letter is not available', 75 | error: 76 | "Bad Request to this Letter Id OR You don't have access to this letter.", 77 | }); 78 | } 79 | return new SendLetterDetailResponseDto(letter); 80 | } 81 | 82 | async delete(user: User, id: number): Promise { 83 | const result = await this.sendLetterRepository.softDelete(id); 84 | if (result.affected === 0) { 85 | throw new NotFoundException({ 86 | statusCode: 404, 87 | message: 'This Letter is not available', 88 | error: 89 | "Bad Request to this Letter Id OR You don't have access to this letter.", 90 | }); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/domain/letter/letter.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { User } from 'src/domain/users/entities/user.entity'; 3 | import { CreateDraftLetterDto } from './dto/requests/createDraftLetter.request.dto'; 4 | import { LetterBody } from './entities/letterBody.entity'; 5 | import { SendLetter } from './entities/sendLetter.entity'; 6 | import { SendLetterStatus } from './letter.constants'; 7 | import { InjectRepository } from '@nestjs/typeorm'; 8 | import { Repository } from 'typeorm'; 9 | import { ReceivedLetter } from './entities/receivedLetter.entity'; 10 | import { LetterUtils } from './letter.utils'; 11 | import { KakaoTokenRepository } from 'src/domain/kakao/repository/kakaoToken.memory.repository'; 12 | import { KakaoToken } from 'src/domain/kakao/kakaoToken'; 13 | import { Friend } from 'src/domain/friend/entities/friend.entity'; 14 | import { KakaoService } from 'src/domain/kakao/kakao.service'; 15 | import { TempLetterRepository } from './repository/tempLetter.repository'; 16 | import { KakaoMessageCallbackDto } from './dto/requests/kakaoCallback.request.dto'; 17 | 18 | @Injectable() 19 | export class LetterService { 20 | constructor( 21 | @InjectRepository(User) 22 | private userRepository: Repository, 23 | @InjectRepository(SendLetter) 24 | private sendLetterRepository: Repository, 25 | @InjectRepository(ReceivedLetter) 26 | private receivedLetterRepository: Repository, 27 | @InjectRepository(Friend) 28 | private friendRepository: Repository, 29 | private readonly kakaoService: KakaoService, 30 | private readonly kakaoTokenRepository: KakaoTokenRepository, 31 | private readonly tempLetterRepository: TempLetterRepository, 32 | ) {} 33 | 34 | async createDraftLetter( 35 | user: User, 36 | createDraftLetterDto: CreateDraftLetterDto, 37 | ): Promise { 38 | /* 39 | * Check Recevier is Available 40 | * Save Letter Body(title, content, situationId) 41 | * Save Send Letter to Temporarily Saved 42 | */ 43 | 44 | if ( 45 | createDraftLetterDto?.receiverId == null && 46 | createDraftLetterDto?.receiverNickname == null 47 | ) { 48 | throw new NotFoundException('Receiver is not available'); 49 | } 50 | 51 | const receiver = createDraftLetterDto?.receiverId 52 | ? await this.userRepository.findOne({ 53 | where: { id: createDraftLetterDto.receiverId }, 54 | }) 55 | : null; 56 | 57 | const receiverNickname = createDraftLetterDto?.receiverNickname 58 | ? createDraftLetterDto?.receiverNickname 59 | : receiver?.nickname; 60 | 61 | const letterBody = new LetterBody(); 62 | letterBody.title = createDraftLetterDto.title; 63 | letterBody.content = createDraftLetterDto.content; 64 | letterBody.accessCode = LetterUtils.generateAccessCode(); 65 | letterBody.situationId = createDraftLetterDto.situationId; 66 | 67 | const sendLetter = new SendLetter(); 68 | sendLetter.sender = user; 69 | sendLetter.receiver = receiver; 70 | sendLetter.receiverNickname = receiverNickname; 71 | sendLetter.status = SendLetterStatus.TMP_SAVED; 72 | sendLetter.letterBody = letterBody; 73 | 74 | const result = await this.sendLetterRepository.save(sendLetter); 75 | return result; 76 | } 77 | 78 | async sendLetter(user: User, id: number): Promise { 79 | /* 80 | * Validation of letter IDs and receiver 81 | * Get Access Token through Kakao API 82 | * Create new incoming letters, save sender information, and update the status of outgoing letters 83 | * Send Message to KaKaotalk Friends API 84 | */ 85 | const sendLetter = await this.sendLetterRepository.findOne({ 86 | where: { id: id, sender: { id: user.id } }, 87 | relations: { 88 | receiver: true, 89 | sender: true, 90 | letterBody: true, 91 | }, 92 | }); 93 | 94 | if (!sendLetter) { 95 | throw new NotFoundException({ 96 | statusCode: 404, 97 | message: 'There is no id', 98 | error: 'Bad Request to this Id, There is no id', 99 | }); 100 | } 101 | if (sendLetter.receiver == null) { 102 | throw new NotFoundException({ 103 | statusCode: 404, 104 | message: 'This Letter is not available', 105 | error: 'Bad Request to this Id, This Letter is not available', 106 | }); 107 | } 108 | if (sendLetter.status == SendLetterStatus.SENT) { 109 | throw new NotFoundException({ 110 | statusCode: 404, 111 | message: 'This Letter already sent', 112 | error: 'Bad Request to this Id, This Letter already sent', 113 | }); 114 | } 115 | if (sendLetter.status == SendLetterStatus.SENT) { 116 | throw new NotFoundException({ 117 | statusCode: 404, 118 | message: 'This Letter already sent', 119 | error: 'Bad Request to this Id, This Letter already sent', 120 | }); 121 | } 122 | 123 | const kakaoToken: KakaoToken = await this.kakaoTokenRepository.findByUserId( 124 | user.id, 125 | ); 126 | const acessToken = kakaoToken.getAcessToken(); 127 | 128 | const friend = await this.friendRepository.findOne({ 129 | where: { 130 | user: { id: user.id }, 131 | friendUser: { id: sendLetter.receiver.id }, 132 | }, 133 | }); 134 | 135 | if (!friend) { 136 | throw new NotFoundException({ 137 | statusCode: 404, 138 | message: 'This Friend is not available', 139 | error: 'Bad Request to this Id, This Friend is not available', 140 | }); 141 | } 142 | 143 | const kakaoUuid = friend.kakaoUuid; 144 | 145 | const receivedLetter = new ReceivedLetter(); 146 | receivedLetter.sender = user; 147 | receivedLetter.receiver = sendLetter.receiver; 148 | receivedLetter.letterBody = sendLetter.letterBody; 149 | receivedLetter.senderNickname = sendLetter.sender.nickname; 150 | await this.receivedLetterRepository.save(receivedLetter); 151 | 152 | await this.sendLetterRepository.update(id, { 153 | status: SendLetterStatus.SENT, 154 | }); 155 | 156 | const template_id = 87992; 157 | const template_args = `{\"letterId\": "${receivedLetter.id}"}`; 158 | const result = this.kakaoService.sendKakaoMessage( 159 | acessToken, 160 | kakaoUuid, 161 | template_id, 162 | template_args, 163 | ); 164 | return result; 165 | } 166 | 167 | async sendTempLetter(user: User, id: number): Promise { 168 | const sendLetter = await this.sendLetterRepository.findOne({ 169 | where: { id: id, sender: { id: user.id } }, 170 | relations: { 171 | sender: true, 172 | }, 173 | }); 174 | 175 | if (!sendLetter) { 176 | throw new NotFoundException({ 177 | statusCode: 404, 178 | message: 'There is no id', 179 | error: 'Bad Request to this Id, There is no id', 180 | }); 181 | } 182 | if (sendLetter.receiverNickname == null) { 183 | throw new NotFoundException({ 184 | statusCode: 404, 185 | message: 'There is no receiverNickname', 186 | error: 187 | 'There is no receiverNickname, If you want to send this letter, please set receiverNickname', 188 | }); 189 | } 190 | 191 | await this.tempLetterRepository.save(sendLetter.id); 192 | return { 193 | data: { 194 | tempLetterId: sendLetter.id, 195 | expiredDate: '2023-01-14', 196 | }, 197 | }; 198 | } 199 | 200 | async getKaKaoTempLetterCallback(query: KakaoMessageCallbackDto) { 201 | const letterId = parseInt(query.TEMP_LETTER_ID); 202 | const sendLetterId = await this.tempLetterRepository.save(letterId, true); 203 | return sendLetterId; 204 | } 205 | 206 | async getKaKaoTempLetterCallbackCheck(id: number) { 207 | const result = await this.tempLetterRepository.findById(id); 208 | return { 209 | data: { 210 | sent: result, 211 | }, 212 | }; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/domain/letter/letter.utils.ts: -------------------------------------------------------------------------------- 1 | export class LetterUtils { 2 | public static generateAccessCode(): string { 3 | return this.generateRandomString(6); 4 | } 5 | 6 | private static generateRandomString(size: number): string { 7 | const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; 8 | let result = ''; 9 | const charactersLength = characters.length; 10 | for (let i = 0; i < size; i++) { 11 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 12 | } 13 | return result; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/domain/letter/lettter.received.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Post, 10 | Query, 11 | UploadedFile, 12 | UseGuards, 13 | UseInterceptors, 14 | } from '@nestjs/common'; 15 | import { LetterReceivedService } from './letter.received.service'; 16 | import { 17 | ApiBadRequestResponse, 18 | ApiBearerAuth, 19 | ApiBody, 20 | ApiConsumes, 21 | ApiNotFoundResponse, 22 | ApiOperation, 23 | ApiResponse, 24 | ApiTags, 25 | } from '@nestjs/swagger'; 26 | import { 27 | ReceivedLetterDetailResponseDto, 28 | tempLetterResponseDto, 29 | } from './dto/responses/letterDetail.response.dto'; 30 | import { JwtAuthGuard } from '../../common/guards/jwtAuth.guard'; 31 | import { ReqUser } from '../../common/decorators/user.decorators'; 32 | import { User } from '../users/entities/user.entity'; 33 | import { 34 | ApiPaginationRequst, 35 | ApiPaginationResponse, 36 | } from '../../common/paginations/pagination.swagger'; 37 | import { ReceviedAllResponseDto } from './dto/responses/letterStorage.response.dto'; 38 | import { findAllReceviedLetterDto } from './dto/requests/findAllLetter.request.dto'; 39 | import { CreateExternalTextLetterDto } from './dto/requests/createExternalLetter.request.dto'; 40 | import { CreateExternalImgLetterDto } from './dto/requests/createExternalLetterImg.request.dto'; 41 | import { FileInterceptor } from '@nestjs/platform-express'; 42 | 43 | @Controller('letters/received') 44 | export class LetterReceivedController { 45 | constructor(private readonly letterReceivedService: LetterReceivedService) {} 46 | @ApiTags('Received Letter API') 47 | @ApiOperation({ 48 | summary: '받은 편지함 조회 API', 49 | description: '유저가 받은 편지함 조회을 조회합니다.', 50 | }) 51 | @UseGuards(JwtAuthGuard) 52 | @ApiBearerAuth() 53 | @ApiPaginationRequst() 54 | @ApiPaginationResponse(ReceviedAllResponseDto) 55 | @HttpCode(HttpStatus.OK) 56 | @Get() 57 | async findAll( 58 | @ReqUser() user: User, 59 | @Query() 60 | query: findAllReceviedLetterDto, 61 | ) { 62 | return await this.letterReceivedService.findAll(user, query); 63 | } 64 | 65 | @ApiTags('Received Letter API') 66 | @ApiOperation({ 67 | summary: '받은 편지 상세 조회 API', 68 | description: '편지 상세 조회를 조회합니다.', 69 | }) 70 | @ApiResponse({ 71 | status: HttpStatus.OK, 72 | description: '받은 편지', 73 | type: ReceivedLetterDetailResponseDto, 74 | }) 75 | @UseGuards(JwtAuthGuard) 76 | @ApiBearerAuth() 77 | @Get('/:id') 78 | async findOne(@ReqUser() user: User, @Param('id') id: number) { 79 | const receivedLetter = await this.letterReceivedService.findOne(user, id); 80 | return { data: receivedLetter }; 81 | } 82 | 83 | @ApiTags('Received Letter API') 84 | @ApiOperation({ 85 | summary: '받은 편지 삭제 API', 86 | description: '받은 편지를 삭제합니다.', 87 | }) 88 | @UseGuards(JwtAuthGuard) 89 | @ApiBearerAuth() 90 | @HttpCode(HttpStatus.NO_CONTENT) 91 | @Delete('/:id') 92 | delete(@Param('id') id: number) { 93 | return this.letterReceivedService.delete(id); 94 | } 95 | 96 | @ApiTags('Received Letter API') 97 | @ApiOperation({ 98 | summary: '임시로 받은 편지 조회 API', 99 | description: '임시로 받은 편지를 조회합니다.', 100 | }) 101 | @ApiResponse({ 102 | status: HttpStatus.OK, 103 | description: '임시로 받은 편지 조회', 104 | type: tempLetterResponseDto, 105 | }) 106 | @ApiNotFoundResponse({ 107 | status: HttpStatus.NOT_FOUND, 108 | description: '임시로 받은 편지가 없습니다.', 109 | }) 110 | @ApiBadRequestResponse({ 111 | status: HttpStatus.BAD_REQUEST, 112 | description: '잘못된 요청입니다.', 113 | }) 114 | @Get('/temp/:id') 115 | async findOneTemp(@Param('id') id: number) { 116 | const tempReceivedLetter = await this.letterReceivedService.findOneTemp(id); 117 | return { data: tempReceivedLetter }; 118 | } 119 | 120 | @ApiTags('Received Letter API') 121 | @ApiOperation({ 122 | summary: '외부 텍스트 편지 생성 API', 123 | description: '외부에서 받은 텍스트로 된 편지를 생성합니다.', 124 | }) 125 | @UseGuards(JwtAuthGuard) 126 | @ApiBearerAuth() 127 | @Post('/texts') 128 | @HttpCode(HttpStatus.CREATED) 129 | async createExternalTextLetter( 130 | @ReqUser() user: User, 131 | @Body() createExternalTextLetterDto: CreateExternalTextLetterDto, 132 | ) { 133 | const letter = await this.letterReceivedService.createTextLetter( 134 | user, 135 | createExternalTextLetterDto, 136 | ); 137 | return { data: letter }; 138 | } 139 | 140 | @ApiTags('Received Letter API') 141 | @ApiOperation({ 142 | summary: '외부 이미지 편지 생성 API', 143 | description: '외부에서 받은 이미지로 된 편지를 생성합니다.', 144 | }) 145 | @UseGuards(JwtAuthGuard) 146 | @ApiBearerAuth() 147 | @Post('/images') 148 | @HttpCode(HttpStatus.CREATED) 149 | async createExternalImageLetter( 150 | @ReqUser() user: User, 151 | @Body() createExternalImgLetterDto: CreateExternalImgLetterDto, 152 | ) { 153 | const letter = await this.letterReceivedService.createImageLetter( 154 | user, 155 | createExternalImgLetterDto, 156 | ); 157 | return { data: letter }; 158 | } 159 | 160 | @ApiTags('Received Letter API') 161 | @ApiOperation({ 162 | summary: '이미지 편지 업로드 API', 163 | description: '외부에서 받은 이미지로 된 편지를 업로드합니다.', 164 | }) 165 | @UseGuards(JwtAuthGuard) 166 | @ApiBearerAuth() 167 | @ApiConsumes('multipart/form-data') 168 | @ApiBody({ 169 | schema: { 170 | type: 'object', 171 | properties: { 172 | file: { 173 | type: 'string', 174 | format: 'binary', 175 | }, 176 | }, 177 | }, 178 | }) 179 | @ApiResponse({ 180 | status: HttpStatus.CREATED, 181 | description: '이미지 편지 업로드 성공', 182 | }) 183 | @Post('/images/upload') 184 | @UseInterceptors(FileInterceptor('file')) 185 | async uploadExternalImgLetter(@UploadedFile() file: Express.MulterS3.File) { 186 | const letter = await this.letterReceivedService.uploadExternalLetterImage( 187 | file, 188 | ); 189 | return { data: letter }; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/domain/letter/repository/tempLetter.repository.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | import { InjectRedis } from '@liaoliaots/nestjs-redis'; 3 | 4 | export class TempLetterRepository { 5 | constructor(@InjectRedis('default') private readonly redis: Redis) {} 6 | private readonly ttl = 3600 * 24 * 3; 7 | 8 | async save(id: number, isSent = false): Promise { 9 | const sentResult = isSent ? '1' : '0'; 10 | const tempLetterKey = this.getTokenKey(id); 11 | await this.redis.set(tempLetterKey, sentResult, 'EX', this.ttl); 12 | return id; 13 | } 14 | 15 | async findById(id: number): Promise { 16 | const tempLetterKey = this.getTokenKey(id); 17 | const result = await this.redis.get(tempLetterKey); 18 | return result == '1'; 19 | } 20 | 21 | private getTokenKey(id: number): string { 22 | return `tempLetter:${id.toString()}`; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/domain/letter/test/letter.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { LetterController } from '../letter.controller'; 2 | 3 | describe('LetterController', () => { 4 | let controller: LetterController; 5 | 6 | it('should be defined', () => { 7 | expect(true).toBe(true); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/domain/letter/test/letter.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { LetterService } from '../letter.service'; 2 | 3 | describe('LetterService', () => { 4 | let service: LetterService; 5 | 6 | it('should be defined', () => { 7 | expect(true).toBe(true); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/domain/notice/dto/createNotice.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsString } from 'class-validator'; 3 | 4 | export class CreateNoticeDto { 5 | @IsString() 6 | @ApiProperty({ 7 | example: '반갑습니다.', 8 | description: '공지사항 제목', 9 | }) 10 | readonly title: string; 11 | 12 | @IsString() 13 | @ApiProperty({ 14 | example: '반갑습니다. 이 서비스는 ...', 15 | description: '공지사항 내용', 16 | }) 17 | readonly content: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/domain/notice/dto/deleteNotice.dto.ts: -------------------------------------------------------------------------------- 1 | export class DeleteNoticeDto { 2 | status: string; 3 | msg: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/domain/notice/dto/updateNotice.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CreateNoticeDto } from './createNotice.dto'; 3 | 4 | export class UpdateNoticeDto extends PartialType(CreateNoticeDto) {} 5 | -------------------------------------------------------------------------------- /src/domain/notice/entities/notice.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | 9 | @Entity('notice') 10 | export class Notice { 11 | @PrimaryGeneratedColumn() 12 | id: number; 13 | 14 | @Column({ type: 'varchar', length: 255 }) 15 | title: string; 16 | 17 | @Column({ type: 'text' }) 18 | content: string; 19 | 20 | @CreateDateColumn() 21 | createdAt: Date; 22 | 23 | @UpdateDateColumn() 24 | updatedAt: Date; 25 | } 26 | -------------------------------------------------------------------------------- /src/domain/notice/notice.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Patch, 10 | Post, 11 | } from '@nestjs/common'; 12 | import { CreateNoticeDto } from './dto/createNotice.dto'; 13 | import { UpdateNoticeDto } from './dto/updateNotice.dto'; 14 | import { NoticeService } from './notice.service'; 15 | import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; 16 | import { GetPagination } from 'src/common/decorators/pagination.decorators'; 17 | import { 18 | ApiPaginationRequst, 19 | ApiPaginationResponse, 20 | } from 'src/common/paginations/pagination.swagger'; 21 | import { Notice } from './entities/notice.entity'; 22 | import { PaginationRequest } from 'src/common/paginations/pagination.request'; 23 | 24 | @Controller('notices') 25 | @ApiTags('Notice API') 26 | export class NoticeController { 27 | constructor(private readonly noticeService: NoticeService) {} 28 | 29 | @ApiOperation({ 30 | summary: '모든 공지사항 가져오기 API', 31 | description: '모든 공지사항을 가져옵니다.', 32 | }) 33 | @ApiPaginationRequst() 34 | @ApiPaginationResponse(Notice) 35 | @Get() 36 | findAll(@GetPagination() pagenation: PaginationRequest) { 37 | return this.noticeService.findAll(pagenation); 38 | } 39 | 40 | @ApiOperation({ 41 | summary: '공지사항 상세정보 가져오기 API', 42 | description: '공지사항을 상세정보를 가져옵니다.', 43 | }) 44 | @Get('/:id') 45 | async findOne(@Param('id') id: number) { 46 | const notice = await this.noticeService.findOne(id); 47 | return { data: notice }; 48 | } 49 | 50 | @ApiOperation({ 51 | summary: '공지사항 추가하기 API', 52 | description: '공지사항을 추가합니다.', 53 | }) 54 | @ApiBearerAuth() 55 | @Post() 56 | @HttpCode(HttpStatus.CREATED) 57 | async create(@Body() noticeData: CreateNoticeDto) { 58 | // todo: add validation 59 | const notice = await this.noticeService.create(noticeData); 60 | return { data: notice }; 61 | } 62 | 63 | @ApiOperation({ 64 | summary: '공지사항 수정하기 API', 65 | description: '공지사항을 수정합니다.', 66 | }) 67 | @ApiBearerAuth() 68 | @Patch(':id') 69 | async update(@Param('id') id: number, @Body() noticeDto: UpdateNoticeDto) { 70 | // todo: add validation 71 | const notice = await this.noticeService.update(id, noticeDto); 72 | return { data: notice }; 73 | } 74 | 75 | @ApiOperation({ 76 | summary: '공지사항 삭제하기 API', 77 | description: '공지사항을 삭제합니다.', 78 | }) 79 | @ApiBearerAuth() 80 | @Delete(':id') 81 | @HttpCode(HttpStatus.NO_CONTENT) 82 | delete(@Param('id') id: number) { 83 | // todo: add validation 84 | return this.noticeService.delete(id); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/domain/notice/notice.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { NoticeController } from './notice.controller'; 3 | import { NoticeService } from './notice.service'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Notice } from './entities/notice.entity'; 6 | 7 | @Module({ 8 | imports: [TypeOrmModule.forFeature([Notice])], 9 | controllers: [NoticeController], 10 | providers: [NoticeService], 11 | }) 12 | export class NoticeModule {} 13 | -------------------------------------------------------------------------------- /src/domain/notice/notice.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { CreateNoticeDto } from './dto/createNotice.dto'; 5 | import { DeleteNoticeDto } from './dto/deleteNotice.dto'; 6 | import { UpdateNoticeDto } from './dto/updateNotice.dto'; 7 | import { Notice } from './entities/notice.entity'; 8 | import { PaginationRequest } from 'src/common/paginations/pagination.request'; 9 | import { PaginationResponse } from 'src/common/paginations/pagination.response'; 10 | import { PaginationBuilder } from 'src/common/paginations/paginationBuilder.response'; 11 | 12 | @Injectable() 13 | export class NoticeService { 14 | constructor( 15 | @InjectRepository(Notice) 16 | private noticeRepository: Repository, 17 | ) {} 18 | 19 | async findAll( 20 | pagenation: PaginationRequest, 21 | ): Promise> { 22 | const [data, count] = await this.noticeRepository.findAndCount({ 23 | skip: pagenation.getSkip(), 24 | take: pagenation.getTake(), 25 | }); 26 | 27 | return new PaginationBuilder() 28 | .setData(data) 29 | .setPage(pagenation.page) 30 | .setTake(pagenation.take) 31 | .setTotalCount(count) 32 | .build(); 33 | } 34 | 35 | async findOne(id: number): Promise { 36 | const notice = await this.noticeRepository.findOneBy({ id }); 37 | if (!notice) { 38 | throw new NotFoundException(`Notice #${id} not found`); 39 | } 40 | return notice; 41 | } 42 | 43 | async create(noticeData: CreateNoticeDto): Promise { 44 | const notice = new Notice(); 45 | notice.title = noticeData.title; 46 | notice.content = noticeData.content; 47 | return await this.noticeRepository.save(notice); 48 | } 49 | 50 | async update(id: number, noticeData: UpdateNoticeDto): Promise { 51 | const notice = await this.noticeRepository.findOneBy({ id }); 52 | 53 | if (!notice) { 54 | throw new NotFoundException(`Notice #${id} not found`); 55 | } 56 | 57 | notice.title = noticeData.title; 58 | notice.content = noticeData.content; 59 | 60 | return await this.noticeRepository.save(notice); 61 | } 62 | 63 | async delete(id: number): Promise { 64 | const notice = await this.noticeRepository.findOneBy({ id }); 65 | 66 | if (!notice) { 67 | throw new NotFoundException(`Notice #${id} not found`); 68 | } 69 | 70 | await this.noticeRepository.delete(id); 71 | const result = new DeleteNoticeDto(); 72 | result.status = 'success'; 73 | result.msg = `Sentence #${id} deleted`; 74 | return result; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/domain/notice/test/notice.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { DeleteNoticeDto } from '../dto/deleteNotice.dto'; 3 | import { Notice } from '../entities/notice.entity'; 4 | import { NoticeController } from '../notice.controller'; 5 | import { NoticeService } from '../notice.service'; 6 | 7 | const mockRepository = () => ({}); 8 | 9 | describe('NoticeController', () => { 10 | let noticeController: NoticeController; 11 | let noticeService: NoticeService; 12 | let notice: Notice; 13 | let deleteNoticeDto: DeleteNoticeDto; 14 | 15 | beforeEach(async () => { 16 | const moduleRef = await Test.createTestingModule({ 17 | controllers: [NoticeController], 18 | providers: [ 19 | NoticeService, 20 | { 21 | provide: 'NoticeRepository', 22 | useFactory: mockRepository, 23 | }, 24 | ], 25 | }).compile(); 26 | 27 | // add dependency of notice service 28 | 29 | noticeService = moduleRef.get(NoticeService); 30 | noticeController = moduleRef.get(NoticeController); 31 | notice = new Notice(); 32 | }); 33 | 34 | describe('Test FindAll', () => { 35 | it('should return an array of notice', async () => { 36 | const result = [notice]; 37 | jest 38 | .spyOn(noticeService, 'findAll') 39 | .mockImplementation(() => Promise.resolve(result)); 40 | 41 | expect(await noticeController.findAll()).toBe(result); 42 | }); 43 | }); 44 | 45 | describe('Test FindOne', () => { 46 | it('should return a notice', async () => { 47 | jest 48 | .spyOn(noticeService, 'findOne') 49 | .mockImplementation(() => Promise.resolve(notice)); 50 | 51 | expect(await noticeController.findOne(1)).toBe(notice); 52 | }); 53 | }); 54 | 55 | describe('Test Create', () => { 56 | it('should create a notice', async () => { 57 | jest 58 | .spyOn(noticeService, 'create') 59 | .mockImplementation(() => Promise.resolve(notice)); 60 | 61 | expect(await noticeController.create(notice)).toBe(notice); 62 | }); 63 | }); 64 | 65 | describe('Test Update', () => { 66 | it('should update a notice', async () => { 67 | jest 68 | .spyOn(noticeService, 'update') 69 | .mockImplementation(() => Promise.resolve(notice)); 70 | 71 | expect(await noticeController.update(1, notice)).toBe(notice); 72 | }); 73 | }); 74 | 75 | describe('Test Remove', () => { 76 | it('should remove a notice', async () => { 77 | jest 78 | .spyOn(noticeService, 'delete') 79 | .mockImplementation(() => Promise.resolve(deleteNoticeDto)); 80 | 81 | expect(await noticeController.delete(1)).toBe(undefined); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/domain/notice/test/notice.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { Repository } from 'typeorm'; 3 | import { NoticeService } from '../notice.service'; 4 | import { Notice } from '../entities/notice.entity'; 5 | import { NotFoundException } from '@nestjs/common'; 6 | 7 | const mockRepository = () => ({ 8 | find: jest.fn(), 9 | findOne: jest.fn(), 10 | findOneBy: jest.fn(), 11 | save: jest.fn(), 12 | create: jest.fn(), 13 | }); 14 | 15 | describe('NoticeService', () => { 16 | let noticeService: NoticeService; 17 | let noticeRepository: Repository; 18 | let notice: Notice; 19 | 20 | beforeEach(async () => { 21 | const moduleRef = await Test.createTestingModule({ 22 | providers: [ 23 | NoticeService, 24 | { 25 | provide: 'NoticeRepository', 26 | useFactory: mockRepository, 27 | }, 28 | ], 29 | }).compile(); 30 | 31 | noticeService = moduleRef.get(NoticeService); 32 | noticeRepository = moduleRef.get>('NoticeRepository'); 33 | notice = new Notice(); 34 | }); 35 | 36 | describe('Test FindAll', () => { 37 | it('should return an array of notice', async () => { 38 | const result = [notice]; 39 | jest 40 | .spyOn(noticeRepository, 'find') 41 | .mockImplementation(() => Promise.resolve(result)); 42 | 43 | expect(await noticeService.findAll()).toBe(result); 44 | }); 45 | }); 46 | 47 | describe('Test FindOne', () => { 48 | it('should return a notice', async () => { 49 | jest 50 | .spyOn(noticeRepository, 'findOneBy') 51 | .mockImplementation(() => Promise.resolve(notice)); 52 | 53 | expect(await noticeService.findOne(1)).toBe(notice); 54 | }); 55 | it('If Notice Not Found', async () => { 56 | jest 57 | .spyOn(noticeRepository, 'findOneBy') 58 | .mockImplementation(() => Promise.resolve(undefined)); 59 | 60 | await expect(noticeService.findOne(1)).rejects.toThrowError( 61 | NotFoundException, 62 | ); 63 | }); 64 | }); 65 | 66 | describe('Test Create', () => { 67 | it('should create a notice', async () => { 68 | jest 69 | .spyOn(noticeRepository, 'save') 70 | .mockImplementation(() => Promise.resolve(notice)); 71 | 72 | expect(await noticeService.create(notice)).toBe(notice); 73 | }); 74 | }); 75 | 76 | describe('Test Update', () => { 77 | it('should update a notice', async () => { 78 | jest 79 | .spyOn(noticeRepository, 'findOneBy') 80 | .mockImplementation(() => Promise.resolve(notice)); 81 | jest 82 | .spyOn(noticeRepository, 'save') 83 | .mockImplementation(() => Promise.resolve(notice)); 84 | 85 | expect(await noticeService.update(1, notice)).toBe(notice); 86 | }); 87 | it('If Notice id Not Found', async () => { 88 | jest 89 | .spyOn(noticeRepository, 'findOneBy') 90 | .mockImplementation(() => Promise.resolve(undefined)); 91 | 92 | await expect(noticeService.update(1, notice)).rejects.toThrowError( 93 | NotFoundException, 94 | ); 95 | }); 96 | }); 97 | 98 | describe('Test Delete', () => { 99 | it('If Notice id Not Found', async () => { 100 | jest 101 | .spyOn(noticeRepository, 'findOneBy') 102 | .mockImplementation(() => Promise.resolve(undefined)); 103 | 104 | await expect(noticeService.delete(1)).rejects.toThrowError( 105 | NotFoundException, 106 | ); 107 | }); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /src/domain/relationship/entities/relationship.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('relationship') 4 | export class Relationship { 5 | @PrimaryGeneratedColumn('increment') 6 | id: number; 7 | 8 | @Column({ type: 'text', nullable: true }) 9 | content: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/domain/reminder/dto/requests/createReminder.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsBoolean, IsDateString, IsNumber, IsString } from 'class-validator'; 3 | 4 | export class CreateReminderDto { 5 | @IsString() 6 | @ApiProperty({ 7 | example: '친구 생일 축하', 8 | description: '리마인더 제목', 9 | }) 10 | readonly title: string; 11 | 12 | @IsString() 13 | @ApiProperty({ 14 | example: '친구 생일 축하 편지 작성하기', 15 | description: '리마인더 내용', 16 | }) 17 | readonly content: string; 18 | 19 | @IsNumber() 20 | @ApiProperty({ 21 | example: 1, 22 | description: '리마인더 상황 id', 23 | }) 24 | readonly situationId: number; 25 | 26 | @IsDateString() 27 | @ApiProperty({ 28 | example: '2021-01-01 00:00:00', 29 | description: '이벤트 발생 시간', 30 | }) 31 | readonly eventAt: Date; 32 | 33 | @IsBoolean() 34 | @ApiProperty({ 35 | example: true, 36 | description: '알림 여부', 37 | }) 38 | readonly alertOn: boolean; 39 | 40 | @IsDateString() 41 | @ApiProperty({ 42 | example: '2021-01-01 00:00:00', 43 | description: '리마인더 알림 시간', 44 | }) 45 | readonly alarmAt: Date; 46 | } 47 | -------------------------------------------------------------------------------- /src/domain/reminder/dto/requests/findAllReminder.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Transform } from 'class-transformer'; 3 | import { IsBoolean, IsOptional } from 'class-validator'; 4 | import { PaginationRequest } from 'src/common/paginations/pagination.request'; 5 | 6 | export class FindAllReminderQueryDto extends PaginationRequest { 7 | @ApiProperty({ required: false, description: '완료 여부' }) 8 | @IsOptional() 9 | @IsBoolean() 10 | @Transform(({ value }) => { 11 | if (value === 'true') return true; 12 | if (value === 'false') return false; 13 | return value; 14 | }) 15 | done?: boolean; 16 | } 17 | -------------------------------------------------------------------------------- /src/domain/reminder/dto/requests/updateReminder.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/swagger'; 2 | import { CreateReminderDto } from './createReminder.request.dto'; 3 | 4 | export class UpdateReminderDto extends PartialType(CreateReminderDto) {} 5 | -------------------------------------------------------------------------------- /src/domain/reminder/dto/responses/reminder.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Reminder } from 'src/domain/reminder/entities/reminder.entity'; 3 | 4 | export class ReminderResponseDto { 5 | @ApiProperty({ 6 | example: 1, 7 | description: 'Reminder id', 8 | }) 9 | id: number; 10 | 11 | @ApiProperty({ 12 | example: '친구 생일 축하', 13 | description: '리마인더 제목', 14 | }) 15 | title: string; 16 | 17 | @ApiProperty({ 18 | example: '친구 생일 축하 편지 작성하기', 19 | description: '리마인더 내용', 20 | }) 21 | content: string; 22 | 23 | @ApiProperty({ 24 | example: '2021-01-01 00:00:00', 25 | description: '이벤트 발생 시간', 26 | }) 27 | eventAt: Date; 28 | 29 | @ApiProperty({ 30 | example: true, 31 | description: '알림 여부', 32 | }) 33 | alertOn: boolean; 34 | 35 | @ApiProperty({ 36 | example: '2021-01-01 00:00:00', 37 | description: '리마인더 알림 시간', 38 | }) 39 | alarmAt: Date; 40 | 41 | @ApiProperty({ 42 | example: true, 43 | description: '완료 여부', 44 | }) 45 | isDone: boolean; 46 | 47 | @ApiProperty({ 48 | example: 1, 49 | description: '상황 ID', 50 | }) 51 | situationId: number; 52 | 53 | constructor(reminder: Reminder) { 54 | this.id = reminder.id; 55 | this.title = reminder.title; 56 | this.content = reminder.content; 57 | this.eventAt = reminder.eventAt; 58 | this.alertOn = reminder.alertOn; 59 | this.alarmAt = reminder.alarmAt; 60 | this.isDone = reminder.isDone; 61 | this.situationId = reminder.situationId; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/domain/reminder/dto/responses/reminderStatus.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class ReminderStautsResponseDto { 4 | @ApiProperty({ 5 | example: 1, 6 | description: 'Reminder id', 7 | }) 8 | id: number; 9 | 10 | @ApiProperty({ 11 | example: true, 12 | description: '완료 여부', 13 | }) 14 | isDone: boolean; 15 | 16 | constructor(id: number, isDone: boolean) { 17 | this.id = id; 18 | this.isDone = isDone; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/domain/reminder/entities/reminder.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | JoinColumn, 7 | ManyToOne, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import { User } from '../../users/entities/user.entity'; 12 | 13 | @Entity('reminder') 14 | export class Reminder { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column({ type: 'varchar', length: 255 }) 19 | title: string; 20 | 21 | @Column({ type: 'text' }) 22 | content: string; 23 | 24 | @Column({ type: 'boolean' }) 25 | alertOn: boolean; 26 | 27 | @Column({ type: 'boolean' }) 28 | isDone: boolean; 29 | 30 | @Column({ type: 'timestamp' }) 31 | alarmAt: Date; 32 | 33 | @Column({ type: 'timestamp' }) 34 | eventAt: Date; 35 | 36 | @CreateDateColumn() 37 | createdAt: Date; 38 | 39 | @UpdateDateColumn() 40 | updatedAt: Date; 41 | 42 | @DeleteDateColumn() 43 | deletedAt: Date; 44 | 45 | @ManyToOne(() => User) 46 | @JoinColumn({ name: 'userId' }) 47 | user: User; 48 | 49 | @Column({ type: 'int' }) 50 | situationId: number; 51 | } 52 | -------------------------------------------------------------------------------- /src/domain/reminder/reminder.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Patch, 10 | Post, 11 | Query, 12 | UseGuards, 13 | } from '@nestjs/common'; 14 | import { ReqUser } from 'src/common/decorators/user.decorators'; 15 | import { JwtAuthGuard } from 'src/common/guards/jwtAuth.guard'; 16 | import { User } from 'src/domain/users/entities/user.entity'; 17 | import { CreateReminderDto } from './dto/requests/createReminder.request.dto'; 18 | import { UpdateReminderDto } from './dto/requests/updateReminder.request.dto'; 19 | import { ReminderService } from './reminder.service'; 20 | import { 21 | ApiBearerAuth, 22 | ApiBody, 23 | ApiForbiddenResponse, 24 | ApiNotFoundResponse, 25 | ApiOperation, 26 | ApiResponse, 27 | ApiTags, 28 | } from '@nestjs/swagger'; 29 | import { ReminderResponseDto } from './dto/responses/reminder.response.dto'; 30 | import { ReminderStautsResponseDto } from './dto/responses/reminderStatus.response.dto'; 31 | import { FindAllReminderQueryDto } from './dto/requests/findAllReminder.request.dto'; 32 | import { 33 | ApiPaginationRequst, 34 | ApiPaginationResponse, 35 | } from 'src/common/paginations/pagination.swagger'; 36 | 37 | @Controller('reminders') 38 | @ApiTags('Reminder API') 39 | @ApiBearerAuth() 40 | @UseGuards(JwtAuthGuard) 41 | @ApiNotFoundResponse({ 42 | description: '리마인더가 존재하지 않습니다.', 43 | }) 44 | @ApiForbiddenResponse({ 45 | description: '리마인더를 수정 또는 생성, 조회할 권한이 없습니다.', 46 | }) 47 | export class ReminderController { 48 | constructor(private readonly reminderService: ReminderService) {} 49 | 50 | @ApiOperation({ 51 | summary: '리마인더 목록 조회 API', 52 | description: '유저의 리마인더 목록을 조회합니다.', 53 | }) 54 | @ApiPaginationRequst() 55 | @ApiPaginationResponse(ReminderResponseDto) 56 | @Get() 57 | findAll(@Query() query: FindAllReminderQueryDto, @ReqUser() user: User) { 58 | return this.reminderService.findAll(query, user); 59 | } 60 | 61 | @ApiOperation({ 62 | summary: '리마인더 상세 조회 API', 63 | description: '유저 리마인더를 상세내용을 조회합니다.', 64 | }) 65 | @ApiResponse({ 66 | status: HttpStatus.OK, 67 | description: '리마인더 상세내용을 반환합니다.', 68 | type: ReminderResponseDto, 69 | }) 70 | @Get(':id') 71 | async findOne(@Param('id') id: number, @ReqUser() user: User) { 72 | const reminder = await this.reminderService.findOne(id, user); 73 | return { data: reminder }; 74 | } 75 | 76 | @ApiOperation({ 77 | summary: '리마인더 생성 API', 78 | description: '리마인더를 생성합니다.', 79 | }) 80 | @ApiResponse({ 81 | status: HttpStatus.CREATED, 82 | description: '리마인더 생성 성공', 83 | type: ReminderResponseDto, 84 | }) 85 | @ApiBody({ 86 | type: CreateReminderDto, 87 | }) 88 | @Post() 89 | @HttpCode(HttpStatus.CREATED) 90 | async create(@Body() reminderDto: CreateReminderDto, @ReqUser() user: User) { 91 | const reminder = await this.reminderService.createReminder( 92 | reminderDto, 93 | user, 94 | ); 95 | return { data: reminder }; 96 | } 97 | 98 | @ApiOperation({ 99 | summary: '리마인더 수정 API', 100 | description: '리마인더를 수정합니다.', 101 | }) 102 | @ApiResponse({ 103 | status: HttpStatus.OK, 104 | description: '리마인더 수정 성공후 상세내용을 반환합니다.', 105 | type: ReminderResponseDto, 106 | }) 107 | @ApiBody({ 108 | type: UpdateReminderDto, 109 | }) 110 | @Patch(':id') 111 | async update( 112 | @Param('id') id: number, 113 | @Body() reminderDto: UpdateReminderDto, 114 | @ReqUser() user: User, 115 | ) { 116 | const reminder = await this.reminderService.update(id, reminderDto, user); 117 | return { data: reminder }; 118 | } 119 | 120 | @ApiOperation({ 121 | summary: '리마인더 완료 API', 122 | description: '리마인더의 상태를 완료로 변경합니다.', 123 | }) 124 | @ApiResponse({ 125 | status: HttpStatus.OK, 126 | description: '리마인더 완료 상태변경 성공', 127 | type: ReminderStautsResponseDto, 128 | }) 129 | @Patch('done/:id') 130 | async done(@Param('id') id: number, @ReqUser() user: User) { 131 | const reminder = await this.reminderService.done(id, user); 132 | return { data: reminder }; 133 | } 134 | 135 | @ApiOperation({ 136 | summary: '리마인더 미완료 API', 137 | description: '리마인더의 상태를 미완료로 변경합니다.', 138 | }) 139 | @ApiResponse({ 140 | status: HttpStatus.OK, 141 | description: '리마인더 미완료 상태변경 성공', 142 | type: ReminderStautsResponseDto, 143 | }) 144 | @Patch('undone/:id') 145 | async undone(@Param('id') id: number, @ReqUser() user: User) { 146 | const reminder = await this.reminderService.undone(id, user); 147 | return { data: reminder }; 148 | } 149 | 150 | @ApiOperation({ 151 | summary: '리마인더 삭제 API', 152 | description: '리마인더를 삭제합니다.', 153 | }) 154 | @ApiResponse({ 155 | status: HttpStatus.NO_CONTENT, 156 | description: '리마인더 삭제 성공', 157 | }) 158 | @HttpCode(HttpStatus.NO_CONTENT) 159 | @Delete(':id') 160 | delete(@Param('id') id: number, @ReqUser() user: User) { 161 | return this.reminderService.delete(id, user); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/domain/reminder/reminder.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { AuthModule } from 'src/auth/auth.module'; 5 | import { JwtStrategy } from 'src/auth/strategy/jwt.strategy'; 6 | import { User } from 'src/domain/users/entities/user.entity'; 7 | import { Reminder } from './entities/reminder.entity'; 8 | import { ReminderController } from './reminder.controller'; 9 | import { ReminderService } from './reminder.service'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([Reminder, User]), 14 | PassportModule, 15 | AuthModule, 16 | ], 17 | controllers: [ReminderController], 18 | providers: [ReminderService, JwtStrategy], 19 | }) 20 | export class ReminderModule {} 21 | -------------------------------------------------------------------------------- /src/domain/reminder/reminder.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { User } from 'src/domain/users/entities/user.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { CreateReminderDto } from './dto/requests/createReminder.request.dto'; 6 | import { Reminder } from './entities/reminder.entity'; 7 | import { UpdateReminderDto } from './dto/requests/updateReminder.request.dto'; 8 | import { ReminderResponseDto } from './dto/responses/reminder.response.dto'; 9 | import { ReminderStautsResponseDto } from './dto/responses/reminderStatus.response.dto'; 10 | import { FindAllReminderQueryDto } from './dto/requests/findAllReminder.request.dto'; 11 | import { PaginationBuilder } from 'src/common/paginations/paginationBuilder.response'; 12 | 13 | @Injectable() 14 | export class ReminderService { 15 | constructor( 16 | @InjectRepository(Reminder) 17 | private reminderRepository: Repository, 18 | ) {} 19 | 20 | async createReminder(reminderDto: CreateReminderDto, user: User) { 21 | const reminder = new Reminder(); 22 | reminder.title = reminderDto.title; 23 | reminder.content = reminderDto.content; 24 | reminder.eventAt = reminderDto.eventAt; 25 | reminder.alertOn = reminderDto.alertOn; 26 | reminder.alarmAt = reminderDto.alarmAt; 27 | reminder.isDone = false; 28 | reminder.user = user; 29 | reminder.situationId = reminderDto.situationId; 30 | await this.reminderRepository.save(reminder); 31 | return new ReminderResponseDto(reminder); 32 | } 33 | 34 | async findAll(query: FindAllReminderQueryDto, user: User) { 35 | const constraint = { 36 | user: { 37 | id: user.id, 38 | }, 39 | }; 40 | 41 | if (query.done !== undefined) { 42 | constraint['isDone'] = query.done; 43 | } 44 | 45 | const take = query.getTake(); 46 | const skip = query.getSkip(); 47 | 48 | const [data, count] = await this.reminderRepository.findAndCount({ 49 | where: constraint, 50 | skip: skip, 51 | take: take, 52 | select: [ 53 | 'id', 54 | 'title', 55 | 'content', 56 | 'eventAt', 57 | 'alertOn', 58 | 'isDone', 59 | 'situationId', 60 | ], 61 | }); 62 | 63 | return new PaginationBuilder() 64 | .setData(data) 65 | .setTotalCount(count) 66 | .setPage(query.getPage()) 67 | .setTake(query.getTake()) 68 | .build(); 69 | } 70 | 71 | async findOne(id: number, user: User): Promise { 72 | const reminder = await this.reminderRepository.findOne({ 73 | where: { 74 | id: id, 75 | user: { 76 | id: user.id, 77 | }, 78 | }, 79 | select: [ 80 | 'id', 81 | 'title', 82 | 'content', 83 | 'eventAt', 84 | 'alertOn', 85 | 'alarmAt', 86 | 'isDone', 87 | 'situationId', 88 | ], 89 | }); 90 | if (!reminder) { 91 | throw new NotFoundException('Reminder not found'); 92 | } 93 | 94 | return new ReminderResponseDto(reminder); 95 | } 96 | 97 | async update( 98 | id: number, 99 | updateReminderDto: UpdateReminderDto, 100 | user: User, 101 | ): Promise { 102 | const reminder = await this.reminderRepository.findOne({ 103 | where: { 104 | id: id, 105 | user: { 106 | id: user.id, 107 | }, 108 | }, 109 | }); 110 | 111 | if (!reminder) { 112 | throw new NotFoundException('Reminder not found'); 113 | } 114 | 115 | reminder.title = updateReminderDto.title 116 | ? updateReminderDto.title 117 | : reminder.title; 118 | reminder.content = updateReminderDto.content 119 | ? updateReminderDto.content 120 | : reminder.content; 121 | reminder.eventAt = updateReminderDto.eventAt 122 | ? updateReminderDto.eventAt 123 | : reminder.eventAt; 124 | reminder.alertOn = updateReminderDto.alertOn 125 | ? updateReminderDto.alertOn 126 | : reminder.alertOn; 127 | reminder.alarmAt = updateReminderDto.alarmAt 128 | ? updateReminderDto.alarmAt 129 | : reminder.alarmAt; 130 | reminder.situationId = updateReminderDto.situationId 131 | ? updateReminderDto.situationId 132 | : reminder.situationId; 133 | 134 | await this.reminderRepository.save(reminder); 135 | 136 | return new ReminderResponseDto(reminder); 137 | } 138 | 139 | async delete(id: number, user: User) { 140 | await this.reminderRepository.softDelete({ 141 | id: id, 142 | user: { 143 | id: user.id, 144 | }, 145 | }); 146 | } 147 | 148 | async done(id: number, user: User) { 149 | await this.reminderRepository.update( 150 | { 151 | id: id, 152 | user: { 153 | id: user.id, 154 | }, 155 | }, 156 | { isDone: true }, 157 | ); 158 | return new ReminderStautsResponseDto(id, true); 159 | } 160 | 161 | async undone(id: number, user: User) { 162 | await this.reminderRepository.update( 163 | { 164 | id: id, 165 | user: { 166 | id: user.id, 167 | }, 168 | }, 169 | { isDone: false }, 170 | ); 171 | return new ReminderStautsResponseDto(id, false); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/domain/reminder/test/reminder.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { User } from 'src/domain/users/entities/user.entity'; 3 | import { Reminder } from '../entities/reminder.entity'; 4 | import { ReminderController } from '../reminder.controller'; 5 | import { ReminderService } from '../reminder.service'; 6 | import { FindAllReminderQueryDto } from '../dto/requests/findAllReminder.request.dto'; 7 | import { ReminderResponseDto } from '../dto/responses/reminder.response.dto'; 8 | 9 | const mockRepository = () => ({}); 10 | 11 | describe('ReminderController', () => { 12 | let reminderController: ReminderController; 13 | let reminderService: ReminderService; 14 | let reminder: Reminder; 15 | let user: User; 16 | 17 | beforeEach(async () => { 18 | const moduleRef = await Test.createTestingModule({ 19 | controllers: [ReminderController], 20 | providers: [ 21 | ReminderService, 22 | { 23 | provide: 'ReminderRepository', 24 | useFactory: mockRepository, 25 | }, 26 | { 27 | provide: 'UserRepository', 28 | useFactory: mockRepository, 29 | }, 30 | ], 31 | }).compile(); 32 | 33 | reminderService = moduleRef.get(ReminderService); 34 | reminderController = moduleRef.get(ReminderController); 35 | reminder = new Reminder(); 36 | user = new User(); 37 | }); 38 | 39 | describe('Test FindAll', () => { 40 | it('should return an array of reminder', async () => { 41 | const query = new FindAllReminderQueryDto(); 42 | query.done = true; 43 | query.page = 1; 44 | query.take = 10; 45 | 46 | const result = { 47 | count: Number(), 48 | offset: Number(), 49 | limit: Number(), 50 | data: Reminder[10], 51 | }; 52 | const user = new User(); 53 | jest 54 | .spyOn(reminderService, 'findAll') 55 | .mockImplementation(() => Promise.resolve(result)); 56 | 57 | expect(await reminderController.findAll(query, user)).toBe(result); 58 | }); 59 | }); 60 | 61 | describe('Test FindOne', () => { 62 | it('should return an array of reminder', async () => { 63 | const result = { 64 | data: new ReminderResponseDto(reminder), 65 | }; 66 | jest 67 | .spyOn(reminderService, 'findOne') 68 | .mockImplementation(() => 69 | Promise.resolve(new ReminderResponseDto(reminder)), 70 | ); 71 | 72 | expect(await reminderController.findOne(1, user)).toStrictEqual(result); 73 | }); 74 | }); 75 | 76 | describe('Test Create', () => { 77 | it('should return reminder', async () => { 78 | const result = { 79 | data: Reminder, 80 | }; 81 | jest 82 | .spyOn(reminderService, 'createReminder') 83 | .mockImplementation(() => Promise.resolve(reminder)); 84 | 85 | expect(await reminderController.create(reminder, user)).toStrictEqual( 86 | result, 87 | ); 88 | }); 89 | }); 90 | 91 | describe('Test Update', () => { 92 | it('should return reminder', async () => { 93 | const result = { 94 | data: Reminder, 95 | }; 96 | jest 97 | .spyOn(reminderService, 'update') 98 | .mockImplementation(() => Promise.resolve(reminder)); 99 | 100 | expect(await reminderController.update(1, reminder, user)).toStrictEqual( 101 | result, 102 | ); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/domain/reminder/test/reminder.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { User } from 'src/domain/users/entities/user.entity'; 3 | import { Repository } from 'typeorm'; 4 | import { Reminder } from '../entities/reminder.entity'; 5 | import { ReminderService } from '../reminder.service'; 6 | import { ReminderController } from '../reminder.controller'; 7 | import { getRepositoryToken } from '@nestjs/typeorm'; 8 | import { PaginationBuilder } from 'src/common/paginations/paginationBuilder.response'; 9 | import { FindAllReminderQueryDto } from '../dto/requests/findAllReminder.request.dto'; 10 | import { CreateReminderDto } from '../dto/requests/createReminder.request.dto'; 11 | import { ReminderResponseDto } from '../dto/responses/reminder.response.dto'; 12 | import { ReminderStautsResponseDto } from '../dto/responses/reminderStatus.response.dto'; 13 | import { UpdateReminderDto } from '../dto/requests/updateReminder.request.dto'; 14 | import { NotFoundException } from '@nestjs/common'; 15 | 16 | const mockRepository = () => ({ 17 | save: jest.fn(), 18 | findAndCount: jest.fn().mockResolvedValueOnce([[Reminder], 1]), 19 | findOne: jest.fn().mockResolvedValue(Reminder), 20 | update: jest.fn(), 21 | softDelete: jest.fn(), 22 | }); 23 | 24 | type MockRepository = Partial, jest.Mock>>; 25 | 26 | describe('Reminder Service Test', () => { 27 | let reminderService: ReminderService; 28 | let reminderRepository: MockRepository; 29 | const user = new User(); 30 | 31 | beforeEach(async () => { 32 | const moduleRef = await Test.createTestingModule({ 33 | controllers: [ReminderController], 34 | providers: [ 35 | ReminderService, 36 | { 37 | provide: 'ReminderRepository', 38 | useValue: mockRepository(), 39 | }, 40 | ], 41 | }).compile(); 42 | 43 | reminderService = moduleRef.get(ReminderService); 44 | reminderRepository = moduleRef.get(getRepositoryToken(Reminder)); 45 | }); 46 | 47 | describe('FindAll', () => { 48 | const findAllReminderQueryDto = new FindAllReminderQueryDto(); 49 | findAllReminderQueryDto.page = 1; 50 | findAllReminderQueryDto.take = 10; 51 | 52 | const paginationResult = new PaginationBuilder() 53 | .setData([Reminder]) 54 | .setTotalCount(1) 55 | .setPage(1) 56 | .setTake(10) 57 | .build(); 58 | 59 | it('Find All User Reminders', () => { 60 | const result = reminderService.findAll(findAllReminderQueryDto, user); 61 | expect(result).resolves.toEqual(paginationResult); 62 | }); 63 | it('Find All User Done Reminders', () => { 64 | findAllReminderQueryDto.done = true; 65 | const result = reminderService.findAll(findAllReminderQueryDto, user); 66 | expect(result).resolves.toEqual(paginationResult); 67 | }); 68 | it('Find All User Not Done Reminders', () => { 69 | findAllReminderQueryDto.done = false; 70 | const result = reminderService.findAll(findAllReminderQueryDto, user); 71 | expect(result).resolves.toEqual(paginationResult); 72 | }); 73 | it('Find All User Reminders When done is undefined', () => { 74 | findAllReminderQueryDto.done = undefined; 75 | const result = reminderService.findAll(findAllReminderQueryDto, user); 76 | expect(result).resolves.toEqual(paginationResult); 77 | }); 78 | it('Find All User Reminders When done is null', () => { 79 | findAllReminderQueryDto.done = null; 80 | const result = reminderService.findAll(findAllReminderQueryDto, user); 81 | expect(result).resolves.toEqual(paginationResult); 82 | }); 83 | }); 84 | 85 | describe('FindOne', () => { 86 | const reminder = new Reminder(); 87 | const reminderResponseDto = new ReminderResponseDto(reminder); 88 | const id = 1; 89 | it('Find One if exist', () => { 90 | const result = reminderService.findOne(id, user); 91 | expect(result).resolves.toEqual(reminderResponseDto); 92 | }); 93 | it('Can not find reminder', () => { 94 | jest.spyOn(reminderRepository, 'findOne').mockResolvedValue(undefined); 95 | const result = reminderService.findOne(id, user); 96 | expect(result).rejects.toThrowError( 97 | new NotFoundException('Reminder not found'), 98 | ); 99 | }); 100 | it('Find reminder, but not users one', () => { 101 | expect(true).toBe(true); 102 | }); 103 | }); 104 | 105 | describe('Create Reminder', () => { 106 | const createReminder = new CreateReminderDto(); 107 | const reminder = new Reminder(); 108 | reminder.isDone = false; 109 | const reminderResult = new ReminderResponseDto(reminder); 110 | it('Create Reminder', () => { 111 | const result = reminderService.createReminder(createReminder, user); 112 | expect(result).resolves.toEqual(reminderResult); 113 | }); 114 | }); 115 | 116 | describe('Update Reminder', () => { 117 | const id = 1; 118 | const updateReminderDto = new UpdateReminderDto(); 119 | const reminder = new Reminder(); 120 | const reminderResult = new ReminderResponseDto(reminder); 121 | it('Can not find reminder', () => { 122 | jest.spyOn(reminderRepository, 'findOne').mockResolvedValue(undefined); 123 | const result = reminderService.update(id, updateReminderDto, user); 124 | expect(result).rejects.toThrowError( 125 | new NotFoundException('Reminder not found'), 126 | ); 127 | }); 128 | it('Update Reminder', () => { 129 | const result = reminderService.update(id, updateReminderDto, user); 130 | expect(result).resolves.toEqual(reminderResult); 131 | }); 132 | }); 133 | 134 | describe('Delete Reminder', () => { 135 | it('Can not find reminder', () => { 136 | expect(true).toBe(true); 137 | }); 138 | it('Delete Reminder', () => { 139 | expect(true).toBe(true); 140 | }); 141 | }); 142 | 143 | describe('Change Reminder status to done', () => { 144 | const id = 1; 145 | const reminderStautsResponseDto = new ReminderStautsResponseDto(id, true); 146 | it('Can not find reminder', () => { 147 | // todo() 148 | }); 149 | it('Delete Reminder', () => { 150 | const result = reminderService.done(id, user); 151 | expect(result).resolves.toEqual(reminderStautsResponseDto); 152 | }); 153 | }); 154 | 155 | describe('Change Reminder status to undone', () => { 156 | const id = 1; 157 | const reminderStautsResponseDto = new ReminderStautsResponseDto(id, false); 158 | it('Can not find reminder', () => { 159 | // todo() 160 | }); 161 | it('Delete Reminder', () => { 162 | const result = reminderService.undone(id, user); 163 | expect(result).resolves.toEqual(reminderStautsResponseDto); 164 | }); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /src/domain/reply/dtos/requests/createReply.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsNumber, IsString } from 'class-validator'; 3 | 4 | export class CreateReplyDto { 5 | @IsNumber() 6 | @ApiProperty({ 7 | example: 1, 8 | description: '편지 id', 9 | }) 10 | readonly letterBodyId: number; 11 | 12 | @IsString() 13 | @ApiProperty({ 14 | example: '정말 고마워', 15 | description: '답장 내용', 16 | }) 17 | readonly content: string; 18 | } 19 | -------------------------------------------------------------------------------- /src/domain/reply/entities/reply.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity('reply') 4 | export class Reply { 5 | @PrimaryGeneratedColumn('increment') 6 | id: number; 7 | 8 | @Column({ type: 'text', nullable: true }) 9 | content: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/domain/reply/reply.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | HttpStatus, 6 | Post, 7 | UseGuards, 8 | } from '@nestjs/common'; 9 | import { ReqUser } from 'src/common/decorators/user.decorators'; 10 | import { JwtAuthGuard } from 'src/common/guards/jwtAuth.guard'; 11 | import { User } from 'src/domain/users/entities/user.entity'; 12 | import { CreateReplyDto } from './dtos/requests/createReply.request.dto'; 13 | import { ReplyService } from './reply.service'; 14 | import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; 15 | 16 | @Controller('replies') 17 | @UseGuards(JwtAuthGuard) 18 | @ApiBearerAuth() 19 | @ApiTags('Reply API') 20 | export class ReplyController { 21 | constructor(private readonly replyService: ReplyService) {} 22 | 23 | @Post() 24 | @HttpCode(HttpStatus.CREATED) 25 | @ApiOperation({ 26 | summary: '답장 생성 API', 27 | description: '다른 사용자의 글에 답장을 답니다.', 28 | }) 29 | async create(@Body() replyDto: CreateReplyDto, @ReqUser() user: User) { 30 | return await this.replyService.createReply(replyDto, user); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/domain/reply/reply.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { LetterBody } from 'src/domain/letter/entities/letterBody.entity'; 5 | import { Reply } from './entities/reply.entity'; 6 | import { ReplyController } from './reply.controller'; 7 | import { ReplyService } from './reply.service'; 8 | 9 | @Module({ 10 | imports: [TypeOrmModule.forFeature([Reply, LetterBody]), PassportModule], 11 | controllers: [ReplyController], 12 | providers: [ReplyService], 13 | }) 14 | export class ReplyModule {} 15 | -------------------------------------------------------------------------------- /src/domain/reply/reply.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConflictException, 3 | Injectable, 4 | NotFoundException, 5 | } from '@nestjs/common'; 6 | import { InjectRepository } from '@nestjs/typeorm'; 7 | import { LetterBody } from 'src/domain/letter/entities/letterBody.entity'; 8 | import { User } from 'src/domain/users/entities/user.entity'; 9 | import { Repository } from 'typeorm'; 10 | import { CreateReplyDto } from './dtos/requests/createReply.request.dto'; 11 | import { Reply } from './entities/reply.entity'; 12 | 13 | @Injectable() 14 | export class ReplyService { 15 | constructor( 16 | @InjectRepository(Reply) 17 | private replyRepository: Repository, 18 | @InjectRepository(LetterBody) 19 | private letterBodyRepository: Repository, 20 | ) {} 21 | 22 | async createReply(replyDto: CreateReplyDto, user: User) { 23 | // TODO: 답장 쓸수 있는 편지인지 권한 확인 erd 부터 정립필요. 24 | 25 | const letterBody = await this.letterBodyRepository.findOne({ 26 | where: { id: replyDto.letterBodyId }, 27 | relations: ['reply'], 28 | }); 29 | 30 | if (!letterBody) 31 | throw new NotFoundException( 32 | `LetterBody #${replyDto.letterBodyId} not found`, 33 | ); 34 | if (letterBody.reply) throw new ConflictException(`Reply already exists`); 35 | 36 | const reply = new Reply(); 37 | reply.content = replyDto.content; 38 | 39 | letterBody.reply = reply; 40 | 41 | return (await this.letterBodyRepository.save(letterBody)).reply; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/domain/sentence/dto/requests/createSentence.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsBoolean, IsNumber, IsString } from 'class-validator'; 3 | 4 | export class CreateSentenceDto { 5 | @IsString() 6 | @ApiProperty({ 7 | example: '생일 축하해', 8 | description: '문장 내용', 9 | }) 10 | readonly content: string; 11 | 12 | @IsBoolean() 13 | @ApiProperty({ 14 | example: true, 15 | description: '공유 여부', 16 | }) 17 | readonly isShared: boolean; 18 | 19 | @IsNumber() 20 | @ApiProperty({ 21 | example: 1, 22 | description: '문장 상황 id', 23 | }) 24 | readonly situationId: number; 25 | } 26 | -------------------------------------------------------------------------------- /src/domain/sentence/dto/responses/manysentence.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { SentenceResponseDto } from './sentence.response.dto'; 3 | 4 | export class SituationSentenceResponseDto { 5 | @ApiProperty({ 6 | type: [SentenceResponseDto], 7 | description: '사용자 추가 문장(최대 5개)을 배열로 반환합니다.', 8 | }) 9 | userSentence: SentenceResponseDto[]; 10 | 11 | @ApiProperty({ 12 | type: [SentenceResponseDto], 13 | description: '가이드 문장(5개)을 배열로 반환합니다.', 14 | }) 15 | guideSentence: SentenceResponseDto[]; 16 | } 17 | 18 | export class AllSentenceResponseDto { 19 | @ApiProperty({ 20 | type: [SentenceResponseDto], 21 | description: '사용자 추가 문장(최대 5개)을 배열로 반환합니다.', 22 | }) 23 | userSentence: SentenceResponseDto[]; 24 | 25 | @ApiProperty({ 26 | type: [SentenceResponseDto], 27 | description: '가이드 문장(5개)을 배열로 반환합니다.', 28 | }) 29 | guideSentence: SentenceResponseDto[]; 30 | } 31 | -------------------------------------------------------------------------------- /src/domain/sentence/dto/responses/sentence.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Sentence } from 'src/domain/sentence/entities/sentence.entity'; 3 | 4 | export class SentenceResponseDto { 5 | @ApiProperty({ 6 | example: 1, 7 | description: '문장 id', 8 | }) 9 | id: number; 10 | 11 | @ApiProperty({ 12 | example: '생일축하해~', 13 | description: '문장 내용', 14 | }) 15 | content: string; 16 | 17 | @ApiProperty({ 18 | example: 3, 19 | description: '상황 id', 20 | }) 21 | situationId: number; 22 | 23 | @ApiProperty({ 24 | example: 'user', 25 | description: '문장 타입(유저, 공통)', 26 | }) 27 | type: string; 28 | 29 | @ApiProperty({ 30 | example: true, 31 | description: '공유 여부', 32 | }) 33 | isShared: boolean; 34 | 35 | @ApiProperty({ 36 | example: '2021-01-01 00:00:00', 37 | description: '생성 일자', 38 | }) 39 | createdAt: Date; 40 | 41 | constructor(sentence: Sentence) { 42 | this.id = sentence.id; 43 | this.situationId = sentence.situationId; 44 | this.content = sentence.content; 45 | this.type = sentence.type; 46 | this.isShared = sentence.isShared; 47 | this.createdAt = sentence.createdAt; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/domain/sentence/entities/sentence.entity.ts: -------------------------------------------------------------------------------- 1 | import { SentenceType } from 'src/domain/sentence/sentence.constant'; 2 | import { User } from 'src/domain/users/entities/user.entity'; 3 | import { 4 | Column, 5 | CreateDateColumn, 6 | Entity, 7 | JoinColumn, 8 | ManyToOne, 9 | PrimaryGeneratedColumn, 10 | UpdateDateColumn, 11 | } from 'typeorm'; 12 | 13 | @Entity('sentence') 14 | export class Sentence { 15 | @PrimaryGeneratedColumn() 16 | id: number; 17 | 18 | @Column({ type: 'enum', enum: SentenceType, nullable: false }) 19 | type: SentenceType; 20 | 21 | @ManyToOne(() => User) 22 | @JoinColumn({ name: 'userId', referencedColumnName: 'id' }) 23 | user: User; 24 | 25 | @Column({ name: 'userId' }) 26 | userId: number; 27 | 28 | @Column() 29 | content: string; 30 | 31 | @Column({ default: false }) 32 | isShared: boolean; 33 | 34 | @Column({ nullable: true }) 35 | myPreference: number; 36 | 37 | @Column({ nullable: true }) 38 | totalPreference: number; 39 | 40 | @Column({ nullable: true }) 41 | situationId: number; 42 | 43 | @CreateDateColumn() 44 | createdAt: Date; 45 | 46 | @UpdateDateColumn() 47 | updatedAt: Date; 48 | } 49 | -------------------------------------------------------------------------------- /src/domain/sentence/sentence.constant.ts: -------------------------------------------------------------------------------- 1 | export enum SentenceType { 2 | USER = 'user', 3 | GUIDE = 'guide', 4 | } 5 | -------------------------------------------------------------------------------- /src/domain/sentence/sentence.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | HttpCode, 7 | HttpStatus, 8 | Param, 9 | Post, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { JwtAuthGuard } from 'src/common/guards/jwtAuth.guard'; 13 | import { CreateSentenceDto } from './dto/requests/createSentence.request.dto'; 14 | import { SentenceService } from './sentence.service'; 15 | import { 16 | ApiBearerAuth, 17 | ApiNotFoundResponse, 18 | ApiOperation, 19 | ApiResponse, 20 | ApiTags, 21 | } from '@nestjs/swagger'; 22 | import { SentenceResponseDto } from './dto/responses/sentence.response.dto'; 23 | import { ReqUser } from 'src/common/decorators/user.decorators'; 24 | import { User } from 'src/domain/users/entities/user.entity'; 25 | import { SituationSentenceResponseDto } from './dto/responses/manysentence.response.dto'; 26 | 27 | @Controller('sentence') 28 | @ApiTags('Sentence API') 29 | @ApiBearerAuth() 30 | @UseGuards(JwtAuthGuard) 31 | export class SentenceController { 32 | constructor(private readonly sentenceService: SentenceService) {} 33 | 34 | @ApiOperation({ 35 | summary: '상황별 문장 가져오기 API', 36 | description: '상황 id로 사용자 추가 문장, 가이드 문장을 가져옵니다.', 37 | }) 38 | @ApiResponse({ 39 | status: HttpStatus.OK, 40 | description: '상황 id로 사용자 추가 문장, 가이드 문장을 가져옵니다.', 41 | type: SituationSentenceResponseDto, 42 | }) 43 | @Get('situation/:id') 44 | async findSentence(@ReqUser() user: User, @Param('id') situationId: number) { 45 | const userSentence = await this.sentenceService.findUserSentenceBySituation( 46 | user.id, 47 | situationId, 48 | ); 49 | const guideSentence = 50 | await this.sentenceService.findGuideSentenceBySituation(situationId); 51 | 52 | return { data: { userSentence, guideSentence } }; 53 | } 54 | 55 | @ApiOperation({ 56 | summary: '문장 추가하기 API', 57 | description: '문장을 추가합니다.', 58 | }) 59 | @ApiResponse({ 60 | status: HttpStatus.CREATED, 61 | description: '문장이 추가되었습니다.', 62 | type: SentenceResponseDto, 63 | }) 64 | @Post() 65 | @HttpCode(HttpStatus.CREATED) 66 | async createSentence( 67 | @ReqUser() user: User, 68 | @Body() sentenceDto: CreateSentenceDto, 69 | ) { 70 | const sentence = await this.sentenceService.createSentence( 71 | user, 72 | sentenceDto, 73 | ); 74 | return { data: sentence }; 75 | } 76 | 77 | @ApiOperation({ 78 | summary: '내가 쓴 문장 가져오기 API', 79 | description: '내가 쓴 문장을 가져옵니다.', 80 | }) 81 | @ApiResponse({ 82 | status: HttpStatus.OK, 83 | description: '내가 쓴 문장을 문장 id로 가져옵니다', 84 | type: SentenceResponseDto, 85 | }) 86 | @Get(':id') 87 | async findOneSentence(@ReqUser() user: User, @Param('id') id: number) { 88 | const sentence = await this.sentenceService.findOne(user, id); 89 | return { data: sentence }; 90 | } 91 | 92 | @ApiOperation({ 93 | summary: '문장 삭제하기 API', 94 | description: '문장을 삭제합니다.', 95 | }) 96 | @ApiNotFoundResponse({ description: '문장을 찾을 수 없습니다.' }) 97 | @ApiResponse({ 98 | status: HttpStatus.NO_CONTENT, 99 | description: '문장이 삭제되었습니다.', 100 | }) 101 | @Delete(':id') 102 | @HttpCode(HttpStatus.NO_CONTENT) 103 | async deleteSentence(@ReqUser() user: User, @Param('id') id: number) { 104 | return await this.sentenceService.deleteSentence(id, user); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/domain/sentence/sentence.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import { AuthModule } from 'src/auth/auth.module'; 5 | import { JwtStrategy } from 'src/auth/strategy/jwt.strategy'; 6 | import { User } from 'src/domain/users/entities/user.entity'; 7 | import { Sentence } from './entities/sentence.entity'; 8 | import { SentenceController } from './sentence.controller'; 9 | import { SentenceService } from './sentence.service'; 10 | 11 | @Module({ 12 | imports: [ 13 | TypeOrmModule.forFeature([Sentence, User]), 14 | PassportModule, 15 | AuthModule, 16 | ], 17 | controllers: [SentenceController], 18 | providers: [SentenceService, JwtStrategy], 19 | }) 20 | export class SentenceModule {} 21 | -------------------------------------------------------------------------------- /src/domain/sentence/sentence.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { SentenceType } from 'src/domain/sentence/sentence.constant'; 4 | import { User } from 'src/domain/users/entities/user.entity'; 5 | import { Repository } from 'typeorm'; 6 | import { CreateSentenceDto } from './dto/requests/createSentence.request.dto'; 7 | import { SentenceResponseDto } from './dto/responses/sentence.response.dto'; 8 | import { Sentence } from './entities/sentence.entity'; 9 | 10 | @Injectable() 11 | export class SentenceService { 12 | constructor( 13 | @InjectRepository(Sentence) 14 | private sentenceRepository: Repository, 15 | ) {} 16 | 17 | async findOne(user: User, id: number): Promise { 18 | const sentence = await this.sentenceRepository.findOne({ 19 | where: { id: id, userId: user.id }, 20 | }); 21 | 22 | if (!sentence) { 23 | throw new NotFoundException({ 24 | type: 'NOT_FOUND', 25 | message: `Sentence #${id} not found`, 26 | }); 27 | } 28 | 29 | return new SentenceResponseDto(sentence); 30 | } 31 | 32 | async findUserSentenceBySituation(userId: number, situationId: number) { 33 | const sentenceList = await this.sentenceRepository 34 | .createQueryBuilder('sentence') 35 | .where('sentence.userId = :id', { id: userId }) 36 | .andWhere('sentence.type = :type', { type: SentenceType.USER }) 37 | .andWhere('sentence.situationId = :situationId', { 38 | situationId: situationId, 39 | }) 40 | .take(5) 41 | .getMany(); 42 | return sentenceList.map((sentence) => { 43 | return new SentenceResponseDto(sentence); 44 | }); 45 | } 46 | 47 | async findGuideSentenceBySituation(situationId: number) { 48 | const sentenceList = await this.sentenceRepository 49 | .createQueryBuilder('sentence') 50 | .andWhere('sentence.type = :type', { type: SentenceType.GUIDE }) 51 | .andWhere('sentence.situationId = :situationId', { 52 | situationId: situationId, 53 | }) 54 | .orderBy('rand ()') 55 | .take(5) 56 | .getMany(); 57 | return sentenceList.map((sentence) => { 58 | return new SentenceResponseDto(sentence); 59 | }); 60 | } 61 | 62 | async createSentence( 63 | user: User, 64 | sentenceDto: CreateSentenceDto, 65 | ): Promise { 66 | const newSentence = new Sentence(); 67 | newSentence.type = SentenceType.USER; 68 | newSentence.userId = user.id; 69 | newSentence.situationId = sentenceDto.situationId; 70 | newSentence.isShared = sentenceDto.isShared; 71 | newSentence.content = sentenceDto.content; 72 | 73 | const result = await this.sentenceRepository.save(newSentence); 74 | 75 | return new SentenceResponseDto(result); 76 | } 77 | 78 | async deleteSentence(id: number, user: User): Promise { 79 | const deleted = await this.sentenceRepository.delete({ 80 | id, 81 | userId: user.id, 82 | }); 83 | 84 | if (!deleted.affected) 85 | throw new NotFoundException({ 86 | type: 'NOT_FOUND', 87 | message: `Sentence #${id} not found`, 88 | }); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/domain/sentence/test/sentence.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { SentenceController } from '../sentence.controller'; 3 | 4 | describe('SentenceController', () => { 5 | let controller: SentenceController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [SentenceController], 10 | }).compile(); 11 | 12 | controller = module.get(SentenceController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/domain/sentence/test/sentence.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { SentenceService } from '../sentence.service'; 3 | import { Sentence } from '../entities/sentence.entity'; 4 | import { Repository } from 'typeorm'; 5 | import { SentenceController } from '../sentence.controller'; 6 | import { User } from 'src/domain/users/entities/user.entity'; 7 | import { getRepositoryToken } from '@nestjs/typeorm'; 8 | import { SentenceResponseDto } from '../dto/responses/sentence.response.dto'; 9 | import { NotFoundException } from '@nestjs/common'; 10 | import { CreateSentenceDto } from '../dto/requests/createSentence.request.dto'; 11 | import { SentenceType } from 'src/domain/sentence/sentence.constant'; 12 | 13 | const mockRepository = () => ({ 14 | save: jest.fn(), 15 | findAndCount: jest.fn().mockResolvedValueOnce([[Sentence], 1]), 16 | findOne: jest.fn().mockResolvedValue(Sentence), 17 | update: jest.fn(), 18 | softDelete: jest.fn(), 19 | delete: jest.fn(), 20 | }); 21 | 22 | type MockRepository = Partial, jest.Mock>>; 23 | 24 | describe('SentenceService', () => { 25 | let sentenceService: SentenceService; 26 | let sentenceRepository: MockRepository; 27 | const user = new User(); 28 | user.id = 1; 29 | 30 | beforeEach(async () => { 31 | const moduleRef = await Test.createTestingModule({ 32 | controllers: [SentenceController], 33 | providers: [ 34 | SentenceService, 35 | { 36 | provide: 'SentenceRepository', 37 | useValue: mockRepository(), 38 | }, 39 | ], 40 | }).compile(); 41 | 42 | sentenceService = moduleRef.get(SentenceService); 43 | sentenceRepository = moduleRef.get(getRepositoryToken(Sentence)); 44 | }); 45 | 46 | describe('FindOne', () => { 47 | const sentence = new Sentence(); 48 | const sentenceResponseDto = new SentenceResponseDto(sentence); 49 | const id = 1; 50 | it('Find One if exist', () => { 51 | const result = sentenceService.findOne(user, id); 52 | expect(result).resolves.toEqual(sentenceResponseDto); 53 | }); 54 | it('Can not find sentence', () => { 55 | jest.spyOn(sentenceRepository, 'findOne').mockResolvedValue(undefined); 56 | const result = sentenceService.findOne(user, id); 57 | expect(result).rejects.toThrowError( 58 | new NotFoundException({ 59 | type: 'NOT_FOUND', 60 | message: `Sentence #1 not found`, 61 | }), 62 | ); 63 | }); 64 | }); 65 | 66 | describe('create', () => { 67 | const sentenceDto: CreateSentenceDto = { 68 | content: 'test', 69 | isShared: false, 70 | situationId: 1, 71 | }; 72 | const sentence: Sentence = { 73 | id: 1, 74 | situationId: 1, 75 | type: SentenceType.USER, 76 | content: 'test', 77 | userId: 1, 78 | isShared: false, 79 | myPreference: 0, 80 | totalPreference: 0, 81 | createdAt: new Date(), 82 | updatedAt: new Date(), 83 | user: user, 84 | }; 85 | const sentenceResponseDto = new SentenceResponseDto(sentence); 86 | it('Create Sentence', () => { 87 | jest.spyOn(sentenceRepository, 'save').mockResolvedValue(sentence); 88 | const result = sentenceService.createSentence(user, sentenceDto); 89 | expect(result).resolves.toEqual(sentenceResponseDto); 90 | }); 91 | }); 92 | 93 | describe('delete Sentence', () => { 94 | const id = 1; 95 | it('Delete Sentence success', () => { 96 | const deleteResult: { affected: number } = { affected: 1 }; 97 | jest.spyOn(sentenceRepository, 'delete').mockResolvedValue(deleteResult); 98 | sentenceService.deleteSentence(id, user); 99 | }); 100 | it("Can't find sentence", () => { 101 | const deleteResult: { affected: number } = { affected: 0 }; 102 | jest.spyOn(sentenceRepository, 'delete').mockResolvedValue(deleteResult); 103 | const result = sentenceService.deleteSentence(id, user); 104 | expect(result).rejects.toThrowError( 105 | new NotFoundException({ 106 | type: 'NOT_FOUND', 107 | message: `Sentence #1 not found`, 108 | }), 109 | ); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/domain/users/dto/requests/updateUser.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateKakaoUserDto } from 'src/auth/dto/requests/createKakaoUser.dto'; 3 | 4 | export class UpdateUserDto extends PartialType(CreateKakaoUserDto) {} 5 | -------------------------------------------------------------------------------- /src/domain/users/dto/response/user.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | // noinspection HttpUrlsUsage 4 | export class UserResponseDto { 5 | @ApiProperty({ 6 | example: 1, 7 | description: '유저 id', 8 | }) 9 | id: number; 10 | 11 | @ApiProperty({ 12 | example: '김꼬깃', 13 | description: '유저 이름', 14 | }) 15 | name: string; 16 | 17 | @ApiProperty({ 18 | example: 19 | 'http://k.kakaocdn.net/dn/kbBXv/btrThGICmvf/DgD0mvr0IKvCKNkl4oNAI1/img_640x640.jp', 20 | description: '카톡 프로필 이미지 url', 21 | }) 22 | profileImg: string; 23 | 24 | @ApiProperty({ 25 | example: 'true', 26 | description: '꼬깃 메모 알림 설정 여부', 27 | }) 28 | remindOn: boolean; 29 | 30 | @ApiProperty({ 31 | example: 'true', 32 | description: '알림 설정 여부부', 33 | }) 34 | alertOn: boolean; 35 | 36 | @ApiProperty({ 37 | example: 'false', 38 | description: '웰컴 팝업 보여졌는지', 39 | }) 40 | welcomePopupView: boolean; 41 | 42 | @ApiProperty({ 43 | example: 'true', 44 | description: 45 | '친구 목록 가져오기 항목 동의했는지, 안했으면 /auth/friends로 추가항목 동의 후 가져올수 있습니다', 46 | }) 47 | allowFriendsList: boolean; 48 | 49 | constructor(user: any) { 50 | this.id = user.id; 51 | this.name = user.name; 52 | this.profileImg = user.profileImg; 53 | this.remindOn = user.userInfo.remindOn; 54 | this.alertOn = user.userInfo.alertOn; 55 | this.welcomePopupView = user.userInfo.welcomePopupView; 56 | this.allowFriendsList = user.social.allowFriendsList; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/domain/users/entities/social.entity.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | 3 | @Entity({ name: 'social' }) 4 | export class Social { 5 | @PrimaryGeneratedColumn() 6 | id: number; 7 | 8 | @Column() 9 | clientId: string; 10 | 11 | @Column({ default: false }) 12 | allowFriendsList: boolean; 13 | } 14 | -------------------------------------------------------------------------------- /src/domain/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | DeleteDateColumn, 5 | Entity, 6 | JoinColumn, 7 | OneToOne, 8 | PrimaryGeneratedColumn, 9 | UpdateDateColumn, 10 | } from 'typeorm'; 11 | import { Social } from './social.entity'; 12 | import { UserInfo } from './userInfo.entity'; 13 | 14 | @Entity({ name: 'user' }) 15 | export class User { 16 | @PrimaryGeneratedColumn() 17 | id: number; 18 | 19 | @Column() 20 | name: string; 21 | 22 | @Column() 23 | nickname: string; 24 | 25 | @Column() 26 | profileImg: string; 27 | 28 | @CreateDateColumn() 29 | createdAt: Date; 30 | 31 | @UpdateDateColumn() 32 | updatedAt: Date; 33 | 34 | @DeleteDateColumn() 35 | deletedAt: Date; 36 | 37 | @OneToOne(() => Social, { cascade: true }) 38 | @JoinColumn({ name: 'socialId', referencedColumnName: 'id' }) 39 | social: Social; 40 | 41 | @OneToOne(() => UserInfo, { cascade: true }) 42 | @JoinColumn({ name: 'userInfoId', referencedColumnName: 'id' }) 43 | userInfo: UserInfo; 44 | } 45 | -------------------------------------------------------------------------------- /src/domain/users/entities/userInfo.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | CreateDateColumn, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | UpdateDateColumn, 7 | } from 'typeorm'; 8 | 9 | @Entity({ name: 'userInfo' }) 10 | export class UserInfo { 11 | @PrimaryGeneratedColumn() 12 | id: number; 13 | 14 | @Column({ nullable: true }) 15 | birthday: string; 16 | 17 | @Column({ nullable: true }) 18 | email: string; 19 | 20 | @Column({ nullable: true }) 21 | gender: string; 22 | 23 | @Column({ default: true }) 24 | remindOn: boolean; 25 | 26 | @Column({ default: true }) 27 | alertOn: boolean; 28 | 29 | @Column({ default: false }) 30 | welcomePopupView: boolean; 31 | 32 | @CreateDateColumn() 33 | createdAt: Date; 34 | 35 | @UpdateDateColumn() 36 | updatedAt: Date; 37 | } 38 | -------------------------------------------------------------------------------- /src/domain/users/users.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersController } from './users.controller'; 3 | import { UsersService } from './users.service'; 4 | 5 | describe('UsersController', () => { 6 | let controller: UsersController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [UsersController], 11 | providers: [UsersService], 12 | }).compile(); 13 | 14 | controller = module.get(UsersController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/domain/users/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | HttpStatus, 5 | Param, 6 | Patch, 7 | UseGuards, 8 | } from '@nestjs/common'; 9 | import { UsersService } from './users.service'; 10 | import { JwtAuthGuard } from 'src/common/guards/jwtAuth.guard'; 11 | import { AuthService } from 'src/auth/auth.service'; 12 | import { ReqUser } from 'src/common/decorators/user.decorators'; 13 | import { 14 | ApiBearerAuth, 15 | ApiOperation, 16 | ApiResponse, 17 | ApiTags, 18 | } from '@nestjs/swagger'; 19 | import { User } from './entities/user.entity'; 20 | import { UserResponseDto } from './dto/response/user.response.dto'; 21 | 22 | @Controller('users') 23 | @UseGuards(JwtAuthGuard) 24 | @ApiBearerAuth() 25 | @ApiTags('Users API') 26 | export class UsersController { 27 | constructor( 28 | private readonly usersService: UsersService, 29 | private readonly authService: AuthService, 30 | ) {} 31 | 32 | @ApiOperation({ 33 | summary: '유저 목록 가져오기 API', 34 | description: '유저 목록을 가져옵니다.', 35 | }) 36 | @ApiResponse({ 37 | status: HttpStatus.OK, 38 | description: '서비스에 가입된 모든 유저를 가져옵니다', 39 | type: [User], 40 | }) 41 | @Get() 42 | async findAll() { 43 | const users = await this.usersService.findAll(); 44 | return { data: users }; 45 | } 46 | 47 | @ApiOperation({ 48 | summary: '내 정보 조회 API', 49 | description: '내 정보를 가져옵니다.', 50 | }) 51 | @ApiResponse({ 52 | status: HttpStatus.OK, 53 | description: '내 정보를 반환합니다.', 54 | type: UserResponseDto, 55 | }) 56 | @Get('/me') 57 | async findMe(@ReqUser() user: User) { 58 | const me = await this.usersService.findUserByMe(user); 59 | return { data: me }; 60 | } 61 | 62 | @ApiOperation({ 63 | summary: '유저 정보 수정 API', 64 | description: '유저 정보를 수정합니다.', 65 | }) 66 | @Patch(':id') 67 | async update(@Param('id') id: string) { 68 | const user = await this.usersService.update(+id); 69 | return { data: user }; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/domain/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UsersService } from './users.service'; 3 | import { UsersController } from './users.controller'; 4 | import { TypeOrmModule } from '@nestjs/typeorm'; 5 | import { Social } from './entities/social.entity'; 6 | import { User } from './entities/user.entity'; 7 | import { UserInfo } from './entities/userInfo.entity'; 8 | import { AuthModule } from 'src/auth/auth.module'; 9 | import { JwtStrategy } from 'src/auth/strategy/jwt.strategy'; 10 | import { PassportModule } from '@nestjs/passport'; 11 | 12 | @Module({ 13 | imports: [ 14 | TypeOrmModule.forFeature([User, UserInfo, Social]), 15 | PassportModule, 16 | AuthModule, 17 | ], 18 | controllers: [UsersController], 19 | providers: [UsersService, JwtStrategy], 20 | }) 21 | export class UsersModule {} 22 | -------------------------------------------------------------------------------- /src/domain/users/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersService } from './users.service'; 3 | 4 | describe('UsersService', () => { 5 | let service: UsersService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UsersService], 10 | }).compile(); 11 | 12 | service = module.get(UsersService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/domain/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectRepository } from '@nestjs/typeorm'; 3 | import { Repository } from 'typeorm'; 4 | import { UserResponseDto } from './dto/response/user.response.dto'; 5 | import { User } from './entities/user.entity'; 6 | 7 | @Injectable() 8 | export class UsersService { 9 | constructor( 10 | @InjectRepository(User) private userRepository: Repository, 11 | ) {} 12 | 13 | async findUserByMe(user: User) { 14 | const userInfo = await this.userRepository.findOne({ 15 | where: { id: user.id }, 16 | relations: { userInfo: true, social: true }, 17 | select: ['id', 'name', 'profileImg', 'social', 'userInfo'], 18 | }); 19 | return new UserResponseDto(userInfo); 20 | } 21 | 22 | async update(id: number) { 23 | const user = await this.userRepository.findOneBy({ id }); 24 | 25 | if (!user) { 26 | throw new NotFoundException({ 27 | statusCode: 404, 28 | message: 'This User is not available', 29 | error: 'Bad Request to this Id, This User is not available', 30 | }); 31 | } 32 | 33 | // TODO : add update user 34 | return await this.userRepository.save(user); 35 | } 36 | 37 | async findAll() { 38 | return await this.userRepository.find(); 39 | } 40 | 41 | async findUserBySocialId(socialId: number): Promise { 42 | const socialUser = await this.userRepository 43 | .createQueryBuilder('user') 44 | .leftJoinAndSelect('user.social', 'social') 45 | .where('social.clientId = :clientId', { clientId: socialId }) 46 | .getOne(); 47 | 48 | return socialUser; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/infrastructor/S3/s3.multer.ts: -------------------------------------------------------------------------------- 1 | import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; 2 | import multerS3 from 'multer-s3'; 3 | import { S3Client } from '@aws-sdk/client-s3'; 4 | import path from 'path'; 5 | import { ConfigService } from '@nestjs/config'; 6 | 7 | export const multerAttachedImgOptionsFactory = ( 8 | configService: ConfigService, 9 | ): MulterOptions => { 10 | const s3 = new S3Client({ 11 | region: configService.get('AWS_BUCKET_REGION'), 12 | credentials: { 13 | accessKeyId: configService.get('AWS_ACCESS_KEY_ID'), 14 | secretAccessKey: configService.get('AWS_SECRET_ACCESS_KEY'), 15 | }, 16 | }); 17 | 18 | return { 19 | storage: multerS3({ 20 | s3, 21 | bucket: configService.get('AWS_BUCKET_NAME'), 22 | key(_req, file, done) { 23 | const ext = path.extname(file.originalname); 24 | const basename = path.basename(file.originalname, ext); 25 | const timestamp = new Date().valueOf(); 26 | done(null, `attachedimg/${basename}_${timestamp}${ext}`); 27 | }, 28 | }), 29 | limits: { fileSize: 10 * 1024 * 1024 }, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/infrastructor/document/document.swagger.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | 4 | export function setupSwagger(app: INestApplication): void { 5 | const options = new DocumentBuilder() 6 | .setTitle('ggo-geet API Docs') 7 | .setDescription('ggo-geet API description') 8 | .setVersion('1.0.0') 9 | .addBearerAuth({ 10 | type: 'http', 11 | scheme: 'bearer', 12 | name: 'JWT', 13 | in: 'header', 14 | }) 15 | .build(); 16 | 17 | const document = SwaggerModule.createDocument(app, options); 18 | SwaggerModule.setup('api-docs', app, document); 19 | } 20 | -------------------------------------------------------------------------------- /src/infrastructor/monitoring/monitoring.telegram.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | export class TelegramMonitoringService { 6 | private readonly chatId: string; 7 | private readonly botToken: string; 8 | 9 | constructor() { 10 | this.chatId = process.env.TELEGRAM_CHAT_ID; 11 | this.botToken = process.env.TELEGRAM_BOT_TOKEN; 12 | } 13 | 14 | public sendAlert(message: string) { 15 | const config = { 16 | method: 'get', 17 | url: `https://api.telegram.org/bot${this.botToken}/sendMessage?chat_id=${this.chatId}&text=${message}`, 18 | headers: {}, 19 | }; 20 | 21 | axios(config) 22 | .then(function (response) { 23 | console.log(JSON.stringify(response.data)); 24 | }) 25 | .catch(function (error) { 26 | console.log(error); 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | import { setupSwagger } from './infrastructor/document/document.swagger'; 5 | 6 | async function bootstrap() { 7 | const app = await NestFactory.create(AppModule); 8 | app.enableCors({ 9 | origin: function (origin, callback) { 10 | callback(null, true); 11 | }, 12 | }); 13 | app.useGlobalPipes( 14 | new ValidationPipe({ 15 | whitelist: true, 16 | forbidNonWhitelisted: true, 17 | transform: true, 18 | }), 19 | ); 20 | setupSwagger(app); 21 | await app.listen(3000); 22 | } 23 | 24 | bootstrap(); 25 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { Test } from '@nestjs/testing'; 3 | import { AppModule } from './../src/app.module'; 4 | import { INestApplication } from '@nestjs/common'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeAll(async () => { 10 | const moduleFixture = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "esModuleInterop": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------