diff --git a/.gitignore b/.gitignore index 9f15018..6d11712 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .env* !.env.example +**/backup.sql \ No newline at end of file diff --git a/README.md b/README.md index 10b44dc..2215ec4 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,12 @@ $ pnpm dev 위 명령어로 개발용 docker compose를 실행합니다. +### migrate 실행 +```sh +$ pnpm run migrate:run +``` +위 명령어로 migration 실행합니 + ![](./swagger.webp) 경로에 접근하여 API 명세가 올바르게 표시되는지 확인합니다. diff --git a/backend/package.json b/backend/package.json index b3e93a2..dbb1a0c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,7 +19,12 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "migrate:run": "pnpm ts-node ./node_modules/typeorm/cli.js migration:run -d ./src/database/config.ts", + "migrate:revert": "pnpm ts-node ./node_modules/typeorm/cli.js migration:revert -d ./src/database/config.ts", + "migrate:reset": "pnpm migrate:revert && pnpm migrate:run", + "migrate:create": "pnpm ts-node ./node_modules/typeorm/cli.js migration:create ./src/database/migrate/custom", + "migrate:generate": "pnpm ts-node ./node_modules/typeorm/cli.js migration:generate ./src/database/migrate/auto -d ./src/database/config.ts" }, "dependencies": { "@anatine/zod-nestjs": "^2.0.9", @@ -29,6 +34,8 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.4.0", "@nestjs/typeorm": "^10.0.2", + "@types/bcrypt": "^5.0.2", + "bcrypt": "^5.1.1", "dotenv": "^16.4.5", "mysql2": "^3.11.0", "openapi3-ts": "^4.4.0", @@ -50,7 +57,7 @@ "eslint": "^8.42.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", - "jest": "^29.5.0", + "jest": "^29.7.0", "prettier": "^3.0.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", @@ -67,6 +74,9 @@ "ts" ], "rootDir": "src", + "moduleNameMapper": { + "^src/(.*)$": "/$1" + }, "testRegex": ".*\\.spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 52da7e1..d704c63 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: '@nestjs/typeorm': specifier: ^10.0.2 version: 10.0.2(@nestjs/common@10.4.1(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(mysql2@3.11.3)(ts-node@10.9.2(@types/node@20.16.3)(typescript@5.7.2))) + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 + bcrypt: + specifier: ^5.1.1 + version: 5.1.1 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -88,7 +94,7 @@ importers: specifier: ^5.0.0 version: 5.2.1(eslint-config-prettier@9.1.0(eslint@8.57.0))(eslint@8.57.0)(prettier@3.3.3) jest: - specifier: ^29.5.0 + specifier: ^29.7.0 version: 29.7.0(@types/node@20.16.3)(ts-node@10.9.2(@types/node@20.16.3)(typescript@5.7.2)) prettier: specifier: ^3.0.0 @@ -472,6 +478,10 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + '@microsoft/tsdoc@0.15.0': resolution: {integrity: sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==} @@ -642,6 +652,9 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/bcrypt@5.0.2': + resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} + '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} @@ -828,6 +841,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -851,6 +867,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -916,6 +936,14 @@ packages: append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + aproba@2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -975,6 +1003,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcrypt@5.1.1: + resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} + engines: {node: '>= 10.0.0'} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1067,6 +1099,10 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -1134,6 +1170,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1162,6 +1202,9 @@ packages: consola@2.15.3: resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -1257,6 +1300,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -1269,6 +1315,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1540,6 +1590,10 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs-monkey@1.0.6: resolution: {integrity: sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==} @@ -1554,6 +1608,11 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} @@ -1637,6 +1696,9 @@ packages: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1655,6 +1717,10 @@ packages: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -2040,6 +2106,10 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -2112,14 +2182,31 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + mkdirp@2.1.6: resolution: {integrity: sha512-+hEnITedc8LAtIP9u3HJDFIdcLV2vXP33sqLLIzkv1Db1zO/1OxbvYf0Y1OC/S/Qo5dxHXepofhmxL02PsKe+A==} engines: {node: '>=10'} @@ -2169,6 +2256,9 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} @@ -2187,6 +2277,11 @@ packages: node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2195,6 +2290,10 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2494,6 +2593,9 @@ packages: resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} engines: {node: '>= 0.8.0'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2645,6 +2747,10 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + terser-webpack-plugin@5.3.10: resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} engines: {node: '>= 10.13.0'} @@ -2954,6 +3060,9 @@ packages: engines: {node: '>= 8'} hasBin: true + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2988,6 +3097,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@2.5.1: resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} engines: {node: '>= 14'} @@ -3531,6 +3643,21 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.0.3 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.6.3 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + '@microsoft/tsdoc@0.15.0': {} '@nestjs/cli@10.4.5': @@ -3717,6 +3844,10 @@ snapshots: dependencies: '@babel/types': 7.25.6 + '@types/bcrypt@5.0.2': + dependencies: + '@types/node': 20.16.3 + '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 @@ -3973,6 +4104,8 @@ snapshots: '@xtuc/long@4.2.2': {} + abbrev@1.1.1: {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -3992,6 +4125,12 @@ snapshots: acorn@8.12.1: {} + agent-base@6.0.2: + dependencies: + debug: 4.3.6 + transitivePeerDependencies: + - supports-color + ajv-formats@2.1.1(ajv@8.12.0): optionalDependencies: ajv: 8.12.0 @@ -4047,6 +4186,13 @@ snapshots: append-field@1.0.0: {} + aproba@2.0.0: {} + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + arg@4.1.3: {} argparse@1.0.10: @@ -4126,6 +4272,14 @@ snapshots: base64-js@1.5.1: {} + bcrypt@5.1.1: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + node-addon-api: 5.1.0 + transitivePeerDependencies: + - encoding + - supports-color + binary-extensions@2.3.0: {} bl@4.1.0: @@ -4242,6 +4396,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chownr@2.0.0: {} + chrome-trace-event@1.0.4: {} ci-info@3.9.0: {} @@ -4303,6 +4459,8 @@ snapshots: color-name@1.1.4: {} + color-support@1.1.3: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -4332,6 +4490,8 @@ snapshots: consola@2.15.3: {} + console-control-strings@1.1.0: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -4413,12 +4573,16 @@ snapshots: delayed-stream@1.0.0: {} + delegates@1.0.0: {} + denque@2.1.0: {} depd@2.0.0: {} destroy@1.2.0: {} + detect-libc@2.0.3: {} + detect-newline@3.1.0: {} dezalgo@1.0.4: @@ -4755,6 +4919,10 @@ snapshots: jsonfile: 6.1.0 universalify: 2.0.1 + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + fs-monkey@1.0.6: {} fs.realpath@1.0.0: {} @@ -4764,6 +4932,18 @@ snapshots: function-bind@1.1.2: {} + gauge@3.0.2: + dependencies: + aproba: 2.0.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + generate-function@2.3.1: dependencies: is-property: 1.0.2 @@ -4840,6 +5020,8 @@ snapshots: has-symbols@1.0.3: {} + has-unicode@2.0.1: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -4858,6 +5040,13 @@ snapshots: statuses: 2.0.1 toidentifier: 1.0.1 + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.3.6 + transitivePeerDependencies: + - supports-color + human-signals@2.1.0: {} iconv-lite@0.4.24: @@ -5423,6 +5612,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + make-dir@4.0.0: dependencies: semver: 7.6.3 @@ -5478,12 +5671,25 @@ snapshots: minimist@1.2.8: {} + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + minipass@7.1.2: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + mkdirp@0.5.6: dependencies: minimist: 1.2.8 + mkdirp@1.0.4: {} + mkdirp@2.1.6: {} ms@2.0.0: {} @@ -5536,6 +5742,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@5.1.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.17.21 @@ -5548,12 +5756,23 @@ snapshots: node-releases@2.0.18: {} + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + normalize-path@3.0.0: {} npm-run-path@4.0.1: dependencies: path-key: 3.1.1 + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + object-assign@4.1.1: {} object-inspect@1.13.2: {} @@ -5842,6 +6061,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -5992,6 +6213,15 @@ snapshots: tapable@2.2.1: {} + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + terser-webpack-plugin@5.3.10(webpack@5.94.0): dependencies: '@jridgewell/trace-mapping': 0.3.25 @@ -6251,6 +6481,10 @@ snapshots: dependencies: isexe: 2.0.0 + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: @@ -6284,6 +6518,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yaml@2.5.1: {} yargs-parser@20.2.9: {} diff --git a/backend/src/SaneLogger.ts b/backend/src/SaneLogger.ts new file mode 100644 index 0000000..6a84c95 --- /dev/null +++ b/backend/src/SaneLogger.ts @@ -0,0 +1,9 @@ +import { ConsoleLogger, LoggerService } from '@nestjs/common'; + +export class SaneLogger extends ConsoleLogger implements LoggerService { + protected override getTimestamp() { + return new Date() + .toISOString() + .replace(/(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}).*/g, '$1 $2'); + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 395ba0b..11c0aee 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -5,10 +5,19 @@ import { AppService } from './app.service'; import { HistoriesModule } from './histories/histories.module'; import { BooksModule } from './books/books.module'; import { dbConfig } from './config'; +import { UsersModule } from './users/users.module'; +import { ZodValidationPipe } from '@anatine/zod-nestjs'; +import { APP_PIPE } from '@nestjs/core'; @Module({ - imports: [TypeOrmModule.forRoot(dbConfig), HistoriesModule, BooksModule], + imports: [TypeOrmModule.forRoot(dbConfig), HistoriesModule, UsersModule, BooksModule], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_PIPE, + useClass: ZodValidationPipe, + }, + ], }) export class AppModule {} diff --git a/backend/src/books/books.controller.test.ts b/backend/src/books/books.controller.test.ts new file mode 100644 index 0000000..5907ee6 --- /dev/null +++ b/backend/src/books/books.controller.test.ts @@ -0,0 +1,94 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BooksController } from './books.controller'; +import { BooksService } from './books.service'; +import { Order, PaginationOptionsDto } from 'src/common/dtos/page-options.dto'; +import { BookGetResponseDto } from './dto/books.dto'; +import { Book } from 'src/database/entities'; +import { NotFoundException } from '@nestjs/common'; +import { BookDetailResponseDto } from './dto/books.dto'; +import { BookIDDto } from './dto/books.dto'; + +describe('BooksController', () => { + let controller: BooksController; + let service: BooksService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [BooksController], + providers: [ + { + provide: BooksService, + useValue: { + findAll: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(BooksController); + service = module.get(BooksService); + }); + + describe('findAll', () => { + it('should return a list of books with pagination and categories', async () => { + const paginationOption = { + page: 1, + take: 10, + order: Order.ASC, + } as PaginationOptionsDto; + const books = [ + { id: 1, title: 'Book 1', category: { name: 'Category 1' } }, + { id: 2, title: 'Book 2', category: { name: 'Category 1' } }, + { id: 3, title: 'Book 3', category: { name: 'Category 2' } }, + ] as Book[]; + const count = 3; + + jest.spyOn(service, 'findAll').mockResolvedValueOnce([books, count]); + + const result: BookGetResponseDto = + await controller.findAll(paginationOption); + + expect(result).toEqual({ + items: books, + categories: [ + { name: 'Category 1', count: 2 }, + { name: 'Category 2', count: 1 }, + ], + meta: { + itemCount: books.length, + currentPage: paginationOption.page, + itemsPerPage: paginationOption.take, + totalItems: count, + totalPages: Math.ceil(count / paginationOption.take), + }, + }); + }); + + describe('findOne', () => { + it('should return a book detail if found', async () => { + const id = 1; + const book = { + id, + title: 'Book 1', + category: { name: 'Category 1' }, + } as unknown as BookDetailResponseDto; + + jest.spyOn(service, 'findOne').mockResolvedValueOnce(book); + + const result = await controller.findOne({ id }); + + expect(result).toEqual(book); + }); + + it('should throw NotFoundException if book is not found', async () => { + const id = 1; + + jest.spyOn(service, 'findOne').mockResolvedValueOnce(null as never); + + await expect(controller.findOne({ id })).rejects.toThrow( + NotFoundException, + ); + }); + }); + }); +}); diff --git a/backend/src/books/books.controller.ts b/backend/src/books/books.controller.ts index 4dc53dd..69d6b79 100644 --- a/backend/src/books/books.controller.ts +++ b/backend/src/books/books.controller.ts @@ -27,6 +27,7 @@ import { CreateBookCopyResponseDto, BookCopySearchResponseDto, UpdateBookRequestDto, + BookIDDto, } from './dto/books.dto'; import { PaginationOptionsDto } from 'src/common/dtos/page-options.dto'; @@ -77,7 +78,7 @@ export class BooksController { description: '도서 상세 정보 조회 성공', type: BookDetailResponseDto, }) - async findOne(@Param('id') id: number): Promise { + async findOne(@Param() { id }: BookIDDto): Promise { const book = await this.booksService.findOne(id); if (!book) { throw new NotFoundException(`Book with ID ${id} not found`); @@ -95,7 +96,7 @@ export class BooksController { type: CreateBookCopyResponseDto, }) async createCopy( - @Param('id') id: number, + @Param() { id }: BookIDDto, @Body() createBookCopyDto: CreateBookCopyRequestDto, ): Promise { return this.booksService.createCopy(id, createBookCopyDto); @@ -110,7 +111,7 @@ export class BooksController { type: BookCopySearchResponseDto, }) async findCopies( - @Param('id') id: number, + @Param() { id }: BookIDDto, ): Promise { return this.booksService.findCopies(id); } @@ -125,7 +126,7 @@ export class BooksController { type: BookDto, }) async update( - @Param('id') id: number, + @Param() { id }: BookIDDto, @Body() updateBookDto: UpdateBookRequestDto, ): Promise { return this.booksService.update(id, updateBookDto); @@ -138,7 +139,7 @@ export class BooksController { status: 204, description: '도서 삭제 성공', }) - async remove(@Param('id') id: number): Promise { + async remove(@Param() { id }: BookIDDto): Promise { await this.booksService.remove(id); } } diff --git a/backend/src/books/books.module.ts b/backend/src/books/books.module.ts index d1328da..409efc8 100644 --- a/backend/src/books/books.module.ts +++ b/backend/src/books/books.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BooksController } from './books.controller'; import { BooksService } from './books.service'; -import { Book, BookCopy } from '../entities'; +import { Book, BookCopy } from '../database/entities'; @Module({ imports: [TypeOrmModule.forFeature([Book, BookCopy])], diff --git a/backend/src/books/books.service.ts b/backend/src/books/books.service.ts index a18f68a..c32a625 100644 --- a/backend/src/books/books.service.ts +++ b/backend/src/books/books.service.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Book, BookCopy } from '../entities'; +import { Book, BookCopy } from '../database/entities'; import { PaginationOptionsDto } from '../common/dtos/page-options.dto'; import { CreateBookCopyRequestDto, diff --git a/backend/src/books/dto/books.dto.ts b/backend/src/books/dto/books.dto.ts index 311c57f..dec9105 100644 --- a/backend/src/books/dto/books.dto.ts +++ b/backend/src/books/dto/books.dto.ts @@ -79,6 +79,7 @@ export class CategoryCountDto extends createZodDto(categoryCountSchema) {} export class BookDto extends createZodDto(bookSchema) {} export class CategoryDto extends createZodDto(categorySchema) {} export class BookCopyDto extends createZodDto(bookCopySchema) {} +export class BookIDDto extends createZodDto(bookSchema.pick({ id: true })) {} export class BookSearchResultDto extends createZodDto(bookSearchResultSchema) {} export class BookGetResponseDto extends createZodDto(bookGetResponseSchema) {} export class BookDetailResponseDto extends createZodDto( diff --git a/backend/src/categories/categories.service.ts b/backend/src/categories/categories.service.ts index d3f485a..c352d71 100644 --- a/backend/src/categories/categories.service.ts +++ b/backend/src/categories/categories.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { Category } from '../entities/Category'; -import { Book } from '../entities/Book'; +import { Category } from '../database/entities/Category'; +import { Book } from '../database/entities/Book'; import { categoryCountSchema } from 'src/books/dto/books.dto'; @Injectable() diff --git a/backend/src/common/dto/dto.ts b/backend/src/common/dto/dto.ts new file mode 100644 index 0000000..b0069f0 --- /dev/null +++ b/backend/src/common/dto/dto.ts @@ -0,0 +1,13 @@ +import { createZodDto } from '@anatine/zod-nestjs'; +import { + findOneSchema, + paginationRequestSchema, + paginationMetaSchema, +} from '../schema/schema'; + +class PaginationMetaDto extends createZodDto(paginationMetaSchema) {} + +export class PaginationDto { + data: Tdata; + meta: PaginationMetaDto; +} diff --git a/backend/src/common/dtos/page-options.dto.ts b/backend/src/common/dtos/page-options.dto.ts index bb915b1..07e62ad 100644 --- a/backend/src/common/dtos/page-options.dto.ts +++ b/backend/src/common/dtos/page-options.dto.ts @@ -8,7 +8,7 @@ export enum Order { DESC = 'DESC', } export type PaginationOption = z.infer; -const paginationOptionsSchema = z.object({ +export const paginationOptionsSchema = z.object({ order: extendApi(z.nativeEnum(Order).optional().default(Order.ASC), { description: '정렬 순서 (ASC: 오름차순, DESC: 내림차순)', example: Order.ASC, @@ -23,6 +23,10 @@ const paginationOptionsSchema = z.object({ }), }); +export class PaginationOptionsBaseDto extends createZodDto( + paginationOptionsSchema, +) {} + export class PaginationOptionsDto extends createZodDto( paginationOptionsSchema, ) { diff --git a/backend/src/common/schema/schema.ts b/backend/src/common/schema/schema.ts new file mode 100644 index 0000000..3ac9299 --- /dev/null +++ b/backend/src/common/schema/schema.ts @@ -0,0 +1,28 @@ +import { extendApi } from '@anatine/zod-openapi'; +import { z } from 'zod'; + +export const findOneSchema = z.coerce.number().int().min(0); + +export const paginationRequestSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(10).default(10), +}); + +/** + * @description pagination meta schema + * + * @current_page current page + * @limit number of items per page + * @total total number of items + * @total_pages total number of pages + * @next next page number + * @prev previous page number + */ +export const paginationMetaSchema = z.object({ + limit: z.coerce.number().int().min(1), + total: z.coerce.number().int().min(0), + current_page: z.coerce.number().int().min(1), + total_pages: z.coerce.number().int().min(1), + next: z.coerce.number().int().min(1).nullable(), + prev: z.coerce.number().int().min(1).nullable(), +}); diff --git a/backend/src/common/utils/paginate.utils.ts b/backend/src/common/utils/paginate.utils.ts new file mode 100644 index 0000000..36fde2d --- /dev/null +++ b/backend/src/common/utils/paginate.utils.ts @@ -0,0 +1,25 @@ +import { PaginationDto } from '../dto/dto'; + +export async function paginate( + data: T, + total: number, + page: number, + limit: number, +): Promise> { + const totalPages = Math.ceil(total / limit); + const currentPage = page; + const nextPage = page < totalPages ? page + 1 : null; + const prevPage = page > 1 ? page - 1 : null; + + const paginationResponse = new PaginationDto(); + paginationResponse.data = data; + paginationResponse.meta = { + limit: limit, + total: total, + current_page: currentPage, + total_pages: totalPages, + next: nextPage, + prev: prevPage, + }; + return paginationResponse; +} diff --git a/backend/src/common/utils/utils.ts b/backend/src/common/utils/utils.ts new file mode 100644 index 0000000..b5ca205 --- /dev/null +++ b/backend/src/common/utils/utils.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +export function isStringInArrayCaseInsensitive( + str: string, + arr: string[], +): boolean { + return arr.some((item) => item.toLowerCase() === str.toLowerCase()); +} + +type EnumLike = { + [k: string]: string | number; + [nu: number]: string; +}; + +export const zCoercedEnum = (e: T) => + z.preprocess((val) => { + const target = String(val)?.toLowerCase(); + for (const key in Object.values(e)) { + if (String(key)?.toLowerCase() === target) { + return key; + } + } + return null; + }, z.nativeEnum(e)); diff --git a/backend/src/config.ts b/backend/src/config.ts index 91c6130..974102b 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,5 +1,6 @@ import type { TypeOrmModuleOptions } from '@nestjs/typeorm'; import * as dotenv from 'dotenv'; +import * as path from 'path'; dotenv.config(); @@ -10,6 +11,6 @@ export const dbConfig = { username: process.env.MYSQL_USER, password: process.env.MYSQL_PASSWORD, database: process.env.MYSQL_DATABASE, - entities: [__dirname + '/entities/*.{ts,js}'], + entities: [path.join(__dirname, '../**/dist/entities/*{.ts,.js}')], synchronize: false, } satisfies TypeOrmModuleOptions; diff --git a/backend/src/database/config.ts b/backend/src/database/config.ts new file mode 100644 index 0000000..aaa98f4 --- /dev/null +++ b/backend/src/database/config.ts @@ -0,0 +1,23 @@ +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import * as dotenv from 'dotenv'; +import { DataSource, DataSourceOptions } from 'typeorm'; + +dotenv.config(); + +export const dbConfig: DataSourceOptions = { + type: 'mysql', + host: process.env.MYSQL_HOST, + port: 3306, + username: process.env.MYSQL_USER, + password: process.env.MYSQL_PASSWORD, + database: process.env.MYSQL_DATABASE, + entities: [__dirname + '/entities/*{.ts,.js}'], + migrations: [__dirname + '/migrate/*{.ts,.js}'], + migrationsTableName: 'typeorm_migrations', + synchronize: false, +}; + +// TypeOrmModuleOptions 는 extends Partial +export const typeOrmModuleOptions: TypeOrmModuleOptions = dbConfig; + +export const dataSource: DataSource = new DataSource(dbConfig); diff --git a/backend/src/entities/Book.ts b/backend/src/database/entities/Book.ts similarity index 84% rename from backend/src/entities/Book.ts rename to backend/src/database/entities/Book.ts index 33947cc..2065f71 100644 --- a/backend/src/entities/Book.ts +++ b/backend/src/database/entities/Book.ts @@ -13,10 +13,8 @@ import { Category } from './Category'; import { Likes } from './Likes'; import { Reservation } from './Reservation'; import { Reviews } from './Reviews'; -import { SuperTag } from './SuperTag'; import { BookInfoSearchKeywords } from './BookInfoSearchKeywords'; -@Index('categoryId', ['categoryId'], {}) @Entity('book_info') export class Book { @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) @@ -42,13 +40,14 @@ export class Book { @Column('datetime', { name: 'createdAt', - default: () => "'CURRENT_TIMESTAMP(6)'", + default: 'CURRENT_TIMESTAMP(6)', }) createdAt: Date; @Column('datetime', { name: 'updatedAt', - default: () => "'CURRENT_TIMESTAMP(6)'", + default: 'CURRENT_TIMESTAMP(6)', + onUpdate: 'CURRENT_TIMESTAMP(6)', }) updatedAt: Date; @@ -62,7 +61,12 @@ export class Book { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'categoryId', referencedColumnName: 'id' }]) + @JoinColumn([ + { + name: 'categoryId', + referencedColumnName: 'id', + }, + ]) category: Category; @OneToMany(() => Likes, (likes) => likes.bookInfo) @@ -74,9 +78,6 @@ export class Book { @OneToMany(() => Reviews, (reviews) => reviews.bookInfo) reviews?: Reviews[]; - @OneToMany(() => SuperTag, (superTags) => superTags.userId) - superTags?: SuperTag[]; - @OneToOne( () => BookInfoSearchKeywords, (bookInfoSearchKeyword) => bookInfoSearchKeyword.bookInfo, diff --git a/backend/src/entities/BookCopy.ts b/backend/src/database/entities/BookCopy.ts similarity index 76% rename from backend/src/entities/BookCopy.ts rename to backend/src/database/entities/BookCopy.ts index 7e021e5..b96a516 100644 --- a/backend/src/entities/BookCopy.ts +++ b/backend/src/database/entities/BookCopy.ts @@ -1,11 +1,13 @@ import { Column, + CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; import { Book } from './Book'; import { User } from './User'; @@ -13,7 +15,6 @@ import { Lending } from './Lending'; import { Reservation } from './Reservation'; import { BookStatus } from 'src/books/constants'; -@Index('FK_donator_id_from_user', ['donatorId'], {}) @Entity('book') export class BookCopy { @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) @@ -28,19 +29,13 @@ export class BookCopy { @Column('int', { name: 'status' }) status: BookStatus; - @Column('datetime', { - name: 'createdAt', - default: () => "'CURRENT_TIMESTAMP(6)'", - }) + @CreateDateColumn({ name: 'createdAt' }) createdAt?: Date; @Column('int') infoId: number; - @Column('datetime', { - name: 'updatedAt', - default: () => "'CURRENT_TIMESTAMP(6)'", - }) + @UpdateDateColumn({ name: 'updatedAt' }) updatedAt?: Date; @Column('int', { name: 'donatorId', nullable: true }) @@ -53,12 +48,18 @@ export class BookCopy { @JoinColumn([{ name: 'infoId', referencedColumnName: 'id' }]) info?: Book; - @ManyToOne(() => User, (user) => user.books, { + @ManyToOne(() => User, (user) => user.donateBooks, { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'donatorId', referencedColumnName: 'id' }]) - donator2?: User; + @JoinColumn([ + { + name: 'donatorId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_donator_id_from_user', + }, + ]) + donateUser?: User; @OneToMany(() => Lending, (lending) => lending.book) lendings?: Lending[]; diff --git a/backend/src/database/entities/BookInfoSearchKeywords.ts b/backend/src/database/entities/BookInfoSearchKeywords.ts new file mode 100644 index 0000000..cf2a91c --- /dev/null +++ b/backend/src/database/entities/BookInfoSearchKeywords.ts @@ -0,0 +1,63 @@ +import { + Column, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { Book } from './Book'; + +@Entity('book_info_search_keywords') +export class BookInfoSearchKeywords { + @PrimaryGeneratedColumn({ + type: 'int', + name: 'id', + }) + id: number; + + @Column('varchar', { + name: 'disassembled_title', + length: 255, + nullable: true, + }) + disassembledTitle?: string; + + @Column('varchar', { + name: 'disassembled_author', + length: 255, + nullable: true, + }) + disassembledAuthor?: string; + + @Column('varchar', { + name: 'disassembled_publisher', + length: 255, + nullable: true, + }) + disassembledPublisher?: string; + + @Column('varchar', { name: 'title_initials', length: 255, nullable: true }) + titleInitials?: string; + + @Column('varchar', { name: 'author_initials', length: 255, nullable: true }) + authorInitials?: string; + + @Column('varchar', { + name: 'publisher_initials', + length: 255, + nullable: true, + }) + publisherInitials?: string; + + @Column('int', { name: 'book_info_id', nullable: true }) + bookInfoId?: number; + + @OneToOne(() => Book, (bookInfo) => bookInfo.id) + @JoinColumn([ + { + name: 'book_info_id', + referencedColumnName: 'id', + }, + ]) + bookInfo?: Book; +} diff --git a/backend/src/entities/Category.ts b/backend/src/database/entities/Category.ts similarity index 100% rename from backend/src/entities/Category.ts rename to backend/src/database/entities/Category.ts diff --git a/backend/src/entities/Lending.ts b/backend/src/database/entities/Lending.ts similarity index 67% rename from backend/src/entities/Lending.ts rename to backend/src/database/entities/Lending.ts index 62e5082..1fe359e 100644 --- a/backend/src/entities/Lending.ts +++ b/backend/src/database/entities/Lending.ts @@ -1,16 +1,15 @@ import { Column, + CreateDateColumn, Entity, - Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; import { BookCopy } from './BookCopy'; import { User } from './User'; -@Index('FK_f2adde8c7d298210c39c500d966', ['lendingLibrarianId'], {}) -@Index('FK_returningLibrarianId', ['returningLibrarianId'], {}) @Entity('lending', { schema: '42library' }) export class Lending { @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) @@ -19,7 +18,12 @@ export class Lending { @Column('int', { name: 'lendingLibrarianId' }) lendingLibrarianId: number; - @Column('varchar', { name: 'lendingCondition', length: 255 }) + @Column('varchar', { + name: 'lendingCondition', + length: 255, + nullable: false, + default: '', + }) lendingCondition: string; @Column('int', { name: 'returningLibrarianId', nullable: true }) @@ -35,16 +39,10 @@ export class Lending { @Column('datetime', { name: 'returnedAt', nullable: true }) returnedAt: Date | null; - @Column('timestamp', { - name: 'createdAt', - default: () => "'CURRENT_TIMESTAMP(6)'", - }) + @CreateDateColumn({ name: 'createdAt', type: 'timestamp' }) createdAt: Date; - @Column('timestamp', { - name: 'updatedAt', - default: () => "'CURRENT_TIMESTAMP(6)'", - }) + @UpdateDateColumn({ name: 'updatedAt', type: 'timestamp' }) updatedAt: Date; @ManyToOne(() => BookCopy, (book) => book.lendings, { @@ -67,17 +65,29 @@ export class Lending { @Column({ name: 'userId', type: 'int' }) userId: number; - @ManyToOne(() => User, (user) => user.lendings2, { + @ManyToOne(() => User, (user) => user.librarianLendings, { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'lendingLibrarianId', referencedColumnName: 'id' }]) + @JoinColumn([ + { + name: 'lendingLibrarianId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_f2adde8c7d298210c39c500d966', + }, + ]) lendingLibrarian: User; - @ManyToOne(() => User, (user) => user.lendings3, { + @ManyToOne(() => User, (user) => user.librarianReturnings, { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'returningLibrarianId', referencedColumnName: 'id' }]) + @JoinColumn([ + { + name: 'returningLibrarianId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_returningLibrarianId', + }, + ]) returningLibrarian: User; } diff --git a/backend/src/entities/Likes.ts b/backend/src/database/entities/Likes.ts similarity index 72% rename from backend/src/entities/Likes.ts rename to backend/src/database/entities/Likes.ts index 8ed86ee..f3ab7cb 100644 --- a/backend/src/entities/Likes.ts +++ b/backend/src/database/entities/Likes.ts @@ -1,7 +1,6 @@ import { Column, Entity, - Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, @@ -9,8 +8,6 @@ import { import { User } from './User'; import { Book } from './Book'; -@Index('FK_529dceb01ef681127fef04d755d4', ['userId'], {}) -@Index('FK_bookInfo3', ['bookInfoId'], {}) @Entity('likes', { schema: '42library' }) export class Likes { @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) @@ -29,13 +26,25 @@ export class Likes { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'userId', referencedColumnName: 'id' }]) + @JoinColumn([ + { + name: 'userId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_529dceb01ef681127fef04d755d4', + }, + ]) user: User; @ManyToOne(() => Book, (bookInfo) => bookInfo.likes, { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'bookInfoId', referencedColumnName: 'id' }]) + @JoinColumn([ + { + name: 'bookInfoId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_bookInfo3', + }, + ]) bookInfo: Book; } diff --git a/backend/src/entities/Reservation.ts b/backend/src/database/entities/Reservation.ts similarity index 80% rename from backend/src/entities/Reservation.ts rename to backend/src/database/entities/Reservation.ts index 5dc84e8..741eb4c 100644 --- a/backend/src/entities/Reservation.ts +++ b/backend/src/database/entities/Reservation.ts @@ -1,16 +1,16 @@ import { Column, + CreateDateColumn, Entity, - Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; import { User } from './User'; import { Book } from './Book'; import { BookCopy } from './BookCopy'; -@Index('FK_bookInfo', ['bookInfoId'], {}) @Entity('reservation') export class Reservation { @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) @@ -19,16 +19,10 @@ export class Reservation { @Column('datetime', { name: 'endAt', nullable: true }) endAt: Date | null; - @Column('datetime', { - name: 'createdAt', - default: () => 'CURRENT_TIMESTAMP(6)', - }) + @CreateDateColumn({ name: 'createdAt' }) createdAt: Date; - @Column('datetime', { - name: 'updatedAt', - default: () => 'CURRENT_TIMESTAMP(6)', - }) + @UpdateDateColumn({ name: 'updatedAt' }) updatedAt: Date; @Column('int', { name: 'status', default: () => '0' }) @@ -51,7 +45,13 @@ export class Reservation { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'bookInfoId', referencedColumnName: 'id' }]) + @JoinColumn([ + { + name: 'bookInfoId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_bookInfo', + }, + ]) bookInfo: Book; @ManyToOne(() => BookCopy, (book) => book.reservations, { diff --git a/backend/src/entities/Reviews.ts b/backend/src/database/entities/Reviews.ts similarity index 73% rename from backend/src/entities/Reviews.ts rename to backend/src/database/entities/Reviews.ts index 2edb913..67b8236 100644 --- a/backend/src/entities/Reviews.ts +++ b/backend/src/database/entities/Reviews.ts @@ -1,16 +1,15 @@ import { Column, + CreateDateColumn, Entity, - Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; import { User } from './User'; import { Book } from './Book'; -@Index('FK_529dceb01ef681127fef04d755d3', ['userId'], {}) -@Index('FK_bookInfo2', ['bookInfoId'], {}) @Entity('reviews') export class Reviews { @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) @@ -22,16 +21,10 @@ export class Reviews { @Column('int', { name: 'bookInfoId' }) bookInfoId: number; - @Column('datetime', { - name: 'createdAt', - default: () => "'CURRENT_TIMESTAMP(6)'", - }) + @CreateDateColumn({ name: 'createdAt' }) createdAt: Date; - @Column('datetime', { - name: 'updatedAt', - default: () => "'CURRENT_TIMESTAMP(6)'", - }) + @UpdateDateColumn({ name: 'updatedAt' }) updatedAt: Date; @Column('int', { name: 'updateUserId' }) @@ -56,13 +49,25 @@ export class Reviews { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'userId', referencedColumnName: 'id' }]) + @JoinColumn([ + { + name: 'userId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_529dceb01ef681127fef04d755d3', + }, + ]) user: User; @ManyToOne(() => Book, (bookInfo) => bookInfo.reviews, { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'bookInfoId', referencedColumnName: 'id' }]) + @JoinColumn([ + { + name: 'bookInfoId', + referencedColumnName: 'id', + foreignKeyConstraintName: 'FK_bookInfo2', + }, + ]) bookInfo: Book; } diff --git a/backend/src/database/entities/SearchKeywords.ts b/backend/src/database/entities/SearchKeywords.ts new file mode 100644 index 0000000..ffc5ca2 --- /dev/null +++ b/backend/src/database/entities/SearchKeywords.ts @@ -0,0 +1,35 @@ +import { + Column, + Entity, + Index, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { SearchLogs } from './SearchLogs'; + +Index('search_keywords', ['keyword']); +@Entity('search_keywords') +export class SearchKeywords { + @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) + id: number; + + @Column('varchar', { name: 'keyword', length: 255, nullable: true }) + keyword?: string; + + @Column('varchar', { + name: 'disassembled_keyword', + length: 255, + nullable: true, + }) + disassembledKeyword?: string; + + @Column('varchar', { + name: 'initial_consonants', + length: 255, + nullable: true, + }) + initialConsonants?: string; + + @OneToMany(() => SearchLogs, (searchLogs) => searchLogs.searchKeyword) + searchLogs?: SearchLogs[]; +} diff --git a/backend/src/entities/SearchLogs.ts b/backend/src/database/entities/SearchLogs.ts similarity index 60% rename from backend/src/entities/SearchLogs.ts rename to backend/src/database/entities/SearchLogs.ts index 6599933..09f948f 100644 --- a/backend/src/entities/SearchLogs.ts +++ b/backend/src/database/entities/SearchLogs.ts @@ -8,22 +8,30 @@ import { } from 'typeorm'; import { SearchKeywords } from './SearchKeywords'; -@Index('FK_searchKeywordId', ['searchKeywordId'], {}) @Entity('search_logs') export class SearchLogs { @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) - id?: number; + id: number; - @Column('int', { name: 'search_keyword_id' }) + @Column('int', { name: 'search_keyword_id', nullable: true }) searchKeywordId?: number; - @Column('varchar', { name: 'timestamp', length: 255 }) - timestamp?: string; + @Column({ + type: 'timestamp', + nullable: false, + default: () => 'CURRENT_TIMESTAMP', + }) + timestamp: Date; @ManyToOne(() => SearchKeywords, (SearchKeyword) => SearchKeyword.id, { onDelete: 'NO ACTION', onUpdate: 'NO ACTION', }) - @JoinColumn([{ name: 'search_keyword_id', referencedColumnName: 'id' }]) + @JoinColumn([ + { + name: 'search_keyword_id', + referencedColumnName: 'id', + }, + ]) searchKeyword?: SearchKeywords; } diff --git a/backend/src/entities/User.ts b/backend/src/database/entities/User.ts similarity index 75% rename from backend/src/entities/User.ts rename to backend/src/database/entities/User.ts index 42dfc57..ae80495 100644 --- a/backend/src/entities/User.ts +++ b/backend/src/database/entities/User.ts @@ -1,17 +1,17 @@ import { Column, + CreateDateColumn, Entity, Index, OneToMany, PrimaryGeneratedColumn, + UpdateDateColumn, } from 'typeorm'; import { BookCopy } from './BookCopy'; import { Lending } from './Lending'; import { Likes } from './Likes'; import { Reservation } from './Reservation'; import { Reviews } from './Reviews'; -import { SubTag } from './SubTag'; -import { SuperTag } from './SuperTag'; @Index('email', ['email'], { unique: true }) @Index('intraId', ['intraId'], { unique: true }) @@ -50,29 +50,23 @@ export class User { @Column('tinyint', { name: 'role', default: () => '0' }) role: number; - @Column('datetime', { - name: 'createdAt', - default: () => 'CURRENT_TIMESTAMP(6)', - }) + @CreateDateColumn({ name: 'createdAt' }) createdAt: Date; - @Column('datetime', { - name: 'updatedAt', - default: () => 'CURRENT_TIMESTAMP(6)', - }) + @UpdateDateColumn({ name: 'updatedAt' }) updatedAt: Date; - @OneToMany(() => BookCopy, (book) => book.donator2) - books: BookCopy[]; + @OneToMany(() => BookCopy, (book) => book.donateUser) + donateBooks: BookCopy[]; @OneToMany(() => Lending, (lending) => lending.user) lendings: Lending[]; @OneToMany(() => Lending, (lending) => lending.lendingLibrarian) - lendings2: Lending[]; + librarianLendings: Lending[]; @OneToMany(() => Lending, (lending) => lending.returningLibrarian) - lendings3: Lending[]; + librarianReturnings: Lending[]; @OneToMany(() => Likes, (likes) => likes.user) likes: Likes[]; @@ -82,10 +76,4 @@ export class User { @OneToMany(() => Reviews, (reviews) => reviews.user) reviews: Reviews[]; - - @OneToMany(() => SubTag, (subtag) => subtag.userId) - subTag: SubTag[]; - - @OneToMany(() => SuperTag, (superTags) => superTags.userId) - superTags: SuperTag[]; } diff --git a/backend/src/database/entities/index.ts b/backend/src/database/entities/index.ts new file mode 100644 index 0000000..c522262 --- /dev/null +++ b/backend/src/database/entities/index.ts @@ -0,0 +1,11 @@ +export * from './BookCopy'; +export * from './Book'; +export * from './BookInfoSearchKeywords'; +export * from './Category'; +export * from './Lending'; +export * from './Likes'; +export * from './Reservation'; +export * from './Reviews'; +export * from './SearchKeywords'; +export * from './SearchLogs'; +export * from './User'; diff --git a/backend/src/entities/UserReservation.ts b/backend/src/database/legacy_view/UserReservation.ts similarity index 92% rename from backend/src/entities/UserReservation.ts rename to backend/src/database/legacy_view/UserReservation.ts index 3c9f919..85358ec 100644 --- a/backend/src/entities/UserReservation.ts +++ b/backend/src/database/legacy_view/UserReservation.ts @@ -1,6 +1,6 @@ import { ViewEntity, ViewColumn, DataSource } from 'typeorm'; -import { Book } from './Book'; -import { Reservation } from './Reservation'; +import { Book } from '../entities/Book'; +import { Reservation } from '../entities/Reservation'; @ViewEntity({ expression: (Data: DataSource) => diff --git a/backend/src/entities/VHistories.ts b/backend/src/database/legacy_view/VHistories.ts similarity index 100% rename from backend/src/entities/VHistories.ts rename to backend/src/database/legacy_view/VHistories.ts diff --git a/backend/src/entities/VLending.ts b/backend/src/database/legacy_view/VLending.ts similarity index 100% rename from backend/src/entities/VLending.ts rename to backend/src/database/legacy_view/VLending.ts diff --git a/backend/src/entities/VLendingForSearchUser.ts b/backend/src/database/legacy_view/VLendingForSearchUser.ts similarity index 98% rename from backend/src/entities/VLendingForSearchUser.ts rename to backend/src/database/legacy_view/VLendingForSearchUser.ts index 6690c25..9b4d3ed 100644 --- a/backend/src/entities/VLendingForSearchUser.ts +++ b/backend/src/database/legacy_view/VLendingForSearchUser.ts @@ -51,7 +51,7 @@ export class VLendingForSearchUser { duedate: Date; @ViewColumn() - overDueDay: Date; + overDueDay: number; @ViewColumn() reservedNum: number; diff --git a/backend/src/entities/VSearchBook.ts b/backend/src/database/legacy_view/VSearchBook.ts similarity index 93% rename from backend/src/entities/VSearchBook.ts rename to backend/src/database/legacy_view/VSearchBook.ts index 772d0f9..603726e 100644 --- a/backend/src/entities/VSearchBook.ts +++ b/backend/src/database/legacy_view/VSearchBook.ts @@ -1,7 +1,7 @@ import { DataSource, ViewColumn, ViewEntity } from 'typeorm'; -import { Book } from './Book'; -import { BookCopy } from './BookCopy'; -import { Category } from './Category'; +import { Book } from '../entities/Book'; +import { BookCopy } from '../entities/BookCopy'; +import { Category } from '../entities/Category'; @ViewEntity('v_search_book', { expression: (Data: DataSource) => diff --git a/backend/src/entities/VStock.ts b/backend/src/database/legacy_view/VStock.ts similarity index 88% rename from backend/src/entities/VStock.ts rename to backend/src/database/legacy_view/VStock.ts index c1ea390..650c769 100644 --- a/backend/src/entities/VStock.ts +++ b/backend/src/database/legacy_view/VStock.ts @@ -1,9 +1,9 @@ import { DataSource, ViewColumn, ViewEntity } from 'typeorm'; -import { Book } from './Book'; -import { BookCopy } from './BookCopy'; -import { Category } from './Category'; -import { Lending } from './Lending'; -import { Reservation } from './Reservation'; +import { Book } from '../entities/Book'; +import { BookCopy } from '../entities/BookCopy'; +import { Category } from '../entities/Category'; +import { Lending } from '../entities/Lending'; +import { Reservation } from '../entities/Reservation'; @ViewEntity('v_stock', { expression: (Data: DataSource) => diff --git a/backend/src/entities/VUserLending.ts b/backend/src/database/legacy_view/VUserLending.ts similarity index 100% rename from backend/src/entities/VUserLending.ts rename to backend/src/database/legacy_view/VUserLending.ts diff --git a/backend/src/database/migrate/1734850957863-custom.ts b/backend/src/database/migrate/1734850957863-custom.ts new file mode 100644 index 0000000..b8e103d --- /dev/null +++ b/backend/src/database/migrate/1734850957863-custom.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Custom1734850957863 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS migrations`); + await queryRunner.query(`DROP TABLE IF EXISTS sub_tag`); + await queryRunner.query(`DROP TABLE IF EXISTS super_tag`); + await queryRunner.query(`DROP VIEW IF EXISTS user_reservation`); + await queryRunner.query(`DROP VIEW IF EXISTS v_histories`); + await queryRunner.query(`DROP VIEW IF EXISTS v_lending`); + await queryRunner.query(`DROP VIEW IF EXISTS v_lending_for_search_user`); + await queryRunner.query(`DROP VIEW IF EXISTS v_search_book`); + await queryRunner.query(`DROP VIEW IF EXISTS v_user_lending`); + await queryRunner.query(`DROP VIEW IF EXISTS v_stock`); + await queryRunner.query(`DROP VIEW IF EXISTS v_tags_sub_default`); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/src/database/migrate/1734854602845-auto.ts b/backend/src/database/migrate/1734854602845-auto.ts new file mode 100644 index 0000000..af17b8c --- /dev/null +++ b/backend/src/database/migrate/1734854602845-auto.ts @@ -0,0 +1,123 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Auto1734854602845 implements MigrationInterface { + name = 'Auto1734854602845'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`book_info_search_keywords\` DROP FOREIGN KEY \`book_info_search_keywords_ibfk_1\``, + ); + await queryRunner.query( + `ALTER TABLE \`book_info\` DROP FOREIGN KEY \`book_info_ibfk_1\``, + ); + await queryRunner.query( + `ALTER TABLE \`search_logs\` DROP FOREIGN KEY \`search_logs_ibfk_1\``, + ); + await queryRunner.query( + `DROP INDEX \`book_info_id\` ON \`book_info_search_keywords\``, + ); + await queryRunner.query( + `DROP INDEX \`fx_disassembled\` ON \`book_info_search_keywords\``, + ); + await queryRunner.query( + `DROP INDEX \`fx_initials\` ON \`book_info_search_keywords\``, + ); + await queryRunner.query(`DROP INDEX \`categoryId\` ON \`book_info\``); + await queryRunner.query( + `DROP INDEX \`search_keyword_id\` ON \`search_logs\``, + ); + await queryRunner.query( + `DROP INDEX \`fx_search_keywords\` ON \`search_keywords\``, + ); + await queryRunner.query( + `ALTER TABLE \`lending\` CHANGE \`updatedAt\` \`updatedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)`, + ); + await queryRunner.query( + `ALTER TABLE \`book_info_search_keywords\` ADD UNIQUE INDEX \`IDX_759755c27994a9fcf0853bb4de\` (\`book_info_id\`)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX \`IDX_23c05c292c439d77b0de816b50\` ON \`category\` (\`name\`)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX \`IDX_e12875dfb3b1d92d7d7c5377e2\` ON \`user\` (\`email\`)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX \`IDX_bb21f7478f422418fbd5362007\` ON \`user\` (\`intraId\`)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX \`IDX_313d764c4d8f1fff52ac1ee967\` ON \`user\` (\`slack\`)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX \`REL_759755c27994a9fcf0853bb4de\` ON \`book_info_search_keywords\` (\`book_info_id\`)`, + ); + await queryRunner.query( + `ALTER TABLE \`book_info_search_keywords\` ADD CONSTRAINT \`FK_759755c27994a9fcf0853bb4de5\` FOREIGN KEY (\`book_info_id\`) REFERENCES \`book_info\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`book_info\` ADD CONSTRAINT \`FK_34aff905d470a4664465e823b11\` FOREIGN KEY (\`categoryId\`) REFERENCES \`category\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`search_logs\` ADD CONSTRAINT \`FK_f1f1e53d16b6fe52661dd4a1e2c\` FOREIGN KEY (\`search_keyword_id\`) REFERENCES \`search_keywords\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`search_logs\` DROP FOREIGN KEY \`FK_f1f1e53d16b6fe52661dd4a1e2c\``, + ); + await queryRunner.query( + `ALTER TABLE \`book_info\` DROP FOREIGN KEY \`FK_34aff905d470a4664465e823b11\``, + ); + await queryRunner.query( + `ALTER TABLE \`book_info_search_keywords\` DROP FOREIGN KEY \`FK_759755c27994a9fcf0853bb4de5\``, + ); + await queryRunner.query( + `DROP INDEX \`REL_759755c27994a9fcf0853bb4de\` ON \`book_info_search_keywords\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_313d764c4d8f1fff52ac1ee967\` ON \`user\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_bb21f7478f422418fbd5362007\` ON \`user\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_e12875dfb3b1d92d7d7c5377e2\` ON \`user\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_23c05c292c439d77b0de816b50\` ON \`category\``, + ); + await queryRunner.query( + `ALTER TABLE \`book_info_search_keywords\` DROP INDEX \`IDX_759755c27994a9fcf0853bb4de\``, + ); + await queryRunner.query( + `ALTER TABLE \`lending\` CHANGE \`updatedAt\` \`updatedAt\` timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)`, + ); + await queryRunner.query( + `CREATE FULLTEXT INDEX \`fx_search_keywords\` ON \`search_keywords\` (\`disassembled_keyword\`, \`initial_consonants\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`search_keyword_id\` ON \`search_logs\` (\`search_keyword_id\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`categoryId\` ON \`book_info\` (\`categoryId\`)`, + ); + await queryRunner.query( + `CREATE FULLTEXT INDEX \`fx_initials\` ON \`book_info_search_keywords\` (\`title_initials\`, \`author_initials\`, \`publisher_initials\`)`, + ); + await queryRunner.query( + `CREATE FULLTEXT INDEX \`fx_disassembled\` ON \`book_info_search_keywords\` (\`disassembled_title\`, \`disassembled_author\`, \`disassembled_publisher\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`book_info_id\` ON \`book_info_search_keywords\` (\`book_info_id\`)`, + ); + await queryRunner.query( + `ALTER TABLE \`search_logs\` ADD CONSTRAINT \`search_logs_ibfk_1\` FOREIGN KEY (\`search_keyword_id\`) REFERENCES \`search_keywords\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`book_info\` ADD CONSTRAINT \`book_info_ibfk_1\` FOREIGN KEY (\`categoryId\`) REFERENCES \`category\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE \`book_info_search_keywords\` ADD CONSTRAINT \`book_info_search_keywords_ibfk_1\` FOREIGN KEY (\`book_info_id\`) REFERENCES \`book_info\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/backend/src/entities/BookInfoSearchKeywords.ts b/backend/src/entities/BookInfoSearchKeywords.ts deleted file mode 100644 index 794d098..0000000 --- a/backend/src/entities/BookInfoSearchKeywords.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { - Column, - Entity, - Index, - JoinColumn, - OneToOne, - PrimaryGeneratedColumn, -} from 'typeorm'; -import { Book } from './Book'; - -@Index('FK_bookInfoId', ['bookInfoId'], {}) -@Entity('book_info_search_keywords') -export class BookInfoSearchKeywords { - @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) - id?: number; - - @Column('varchar', { name: 'disassembled_title', length: 255 }) - disassembledTitle?: string; - - @Column('varchar', { name: 'disassembled_author', length: 255 }) - disassembledAuthor?: string; - - @Column('varchar', { name: 'disassembled_publisher', length: 255 }) - disassembledPublisher?: string; - - @Column('varchar', { name: 'title_initials', length: 255 }) - titleInitials?: string; - - @Column('varchar', { name: 'author_initials', length: 255 }) - authorInitials?: string; - - @Column('varchar', { name: 'publisher_initials', length: 255 }) - publisherInitials?: string; - - @Column('int', { name: 'book_info_id' }) - bookInfoId?: number; - - @OneToOne(() => Book, (bookInfo) => bookInfo.id) - @JoinColumn([{ name: 'book_info_id', referencedColumnName: 'id' }]) - bookInfo?: Book; -} diff --git a/backend/src/entities/SearchKeywords.ts b/backend/src/entities/SearchKeywords.ts deleted file mode 100644 index b7407de..0000000 --- a/backend/src/entities/SearchKeywords.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; -import { SearchLogs } from './SearchLogs'; - -@Entity('search_keywords') -export class SearchKeywords { - @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) - id?: number; - - @Column('varchar', { name: 'keyword', length: 255 }) - keyword?: string; - - @Column('varchar', { name: 'disassembled_keyword', length: 255 }) - disassembledKeyword?: string; - - @Column('varchar', { name: 'initial_consonants', length: 255 }) - initialConsonants?: string; - - @OneToMany(() => SearchLogs, (searchLogs) => searchLogs.searchKeyword) - searchLogs?: SearchLogs[]; -} diff --git a/backend/src/entities/SubTag.ts b/backend/src/entities/SubTag.ts deleted file mode 100644 index d006849..0000000 --- a/backend/src/entities/SubTag.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - Column, - Entity, - Index, - JoinColumn, - ManyToOne, - PrimaryGeneratedColumn, -} from 'typeorm'; -import { User } from './User'; -import { SuperTag } from './SuperTag'; - -@Index('userId', ['userId'], {}) -@Index('superTagId', ['superTagId'], {}) -@Entity('sub_tag', { schema: 'jip_dev' }) -export class SubTag { - @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) - id: number; - - @Column('int', { name: 'userId' }) - userId: number; - - @Column('int', { name: 'superTagId' }) - superTagId: number; - - @Column('datetime', { - name: 'createdAt', - default: () => 'current_timestamp(6)', - }) - createdAt: Date; - - @Column('datetime', { - name: 'updatedAt', - default: () => 'current_timestamp(6)', - }) - updatedAt: Date; - - @Column('tinyint', { name: 'isDeleted', default: () => '0' }) - isDeleted: number; - - @Column('int', { name: 'updateUserId' }) - updateUserId: number; - - @Column('varchar', { name: 'content', length: 42 }) - content: string; - - @Column('tinyint', { name: 'isPublic' }) - isPublic: number; - - @ManyToOne(() => User, (user) => user.subTag, { - onDelete: 'NO ACTION', - onUpdate: 'NO ACTION', - }) - @JoinColumn([{ name: 'userId', referencedColumnName: 'id' }]) - user: User; - - @ManyToOne(() => SuperTag, (superTag) => superTag.subTags, { - onDelete: 'NO ACTION', - onUpdate: 'NO ACTION', - }) - @JoinColumn([{ name: 'superTagId', referencedColumnName: 'id' }]) - superTag: SuperTag; - - @JoinColumn([{ name: 'bookInfoId', referencedColumnName: 'id' }]) - bookInfoId: number; -} diff --git a/backend/src/entities/SuperTag.ts b/backend/src/entities/SuperTag.ts deleted file mode 100644 index 1eefcad..0000000 --- a/backend/src/entities/SuperTag.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - Column, - Entity, - Index, - JoinColumn, - ManyToOne, - OneToMany, - PrimaryGeneratedColumn, -} from 'typeorm'; -import { SubTag } from './SubTag'; -import { User } from './User'; -import { Book } from './Book'; - -@Index('userId', ['userId'], {}) -@Index('bookInfoId', ['bookInfoId'], {}) -@Entity('super_tag', { schema: 'jip_dev' }) -export class SuperTag { - @PrimaryGeneratedColumn({ type: 'int', name: 'id' }) - id: number; - - @Column('int', { name: 'userId' }) - userId: number; - - @Column('int', { name: 'bookInfoId' }) - bookInfoId: number; - - @Column('datetime', { - name: 'createdAt', - default: () => 'current_timestamp(6)', - }) - createdAt: Date; - - @Column('datetime', { - name: 'updatedAt', - default: () => 'current_timestamp(6)', - }) - updatedAt: Date; - - @Column('tinyint', { name: 'isDeleted', default: () => '0' }) - isDeleted: number; - - @Column('int', { name: 'updateUserId' }) - updateUserId: number; - - @Column('varchar', { name: 'content', length: 42 }) - content: string; - - @OneToMany(() => SubTag, (subTag) => subTag.superTag) - subTags: SubTag[]; - - @ManyToOne(() => User, (user) => user.superTags, { - onDelete: 'NO ACTION', - onUpdate: 'NO ACTION', - }) - @JoinColumn([{ name: 'userId', referencedColumnName: 'id' }]) - user: User; - - @ManyToOne(() => Book, (bookInfo) => bookInfo.superTags, { - onDelete: 'NO ACTION', - onUpdate: 'NO ACTION', - }) - @JoinColumn([{ name: 'bookInfoId', referencedColumnName: 'id' }]) - bookInfo: Book; -} diff --git a/backend/src/entities/VSearchBookByTag.ts b/backend/src/entities/VSearchBookByTag.ts deleted file mode 100644 index e19dc71..0000000 --- a/backend/src/entities/VSearchBookByTag.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { DataSource, ViewColumn, ViewEntity } from 'typeorm'; -import { Book } from './Book'; -import { Category } from './Category'; -import { SubTag } from './SubTag'; -import { SuperTag } from './SuperTag'; - -@ViewEntity('v_search_book_by_tag', { - expression: (Data: DataSource) => - Data.createQueryBuilder() - .distinctOn(['bi.id']) - .select('bi.id', 'id') - .addSelect('bi.title', 'title') - .addSelect('bi.author', 'author') - .addSelect('bi.isbn', 'isbn') - .addSelect('bi.image', 'image') - .addSelect('bi.publishedAt', 'publishedAt') - .addSelect('bi.createdAt', 'createdAt') - .addSelect('bi.updatedAt', 'updatedAt') - .addSelect('c.name', 'category') - .addSelect('sp.content', 'superTagContent') - .addSelect('sb.content', 'subTagContent') - .addSelect( - (subQuery) => - subQuery - .select('COUNT(l.id)') - .from('book', 'b') - .leftJoin('lending', 'l', 'l.bookId = b.id') - .innerJoin('book_info', 'bi2', 'bi2.id = b.infoId') - .where('bi.id = bi.id'), - 'lendingCnt', - ) - .from(Book, 'bi') - .innerJoin(Category, 'c', 'c.id = bi.categoryId') - .innerJoin(SuperTag, 'sp', 'sp.bookInfoId = bi.id') - .leftJoin(SubTag, 'sb', 'sb.superTagId = sp.id'), -}) -export class VSearchBookByTag { - @ViewColumn() - id: number; - - @ViewColumn() - title: string; - - @ViewColumn() - author: string; - - @ViewColumn() - isbn: number; - - @ViewColumn() - image: string; - - @ViewColumn() - publishedAt: string; - - @ViewColumn() - createdAt: string; - - @ViewColumn() - updatedAt: string; - - @ViewColumn() - category: string; - - @ViewColumn() - superTagContent: string; - - @ViewColumn() - subTagContent: string; - - @ViewColumn() - lendingCnt: number; -} - -export default VSearchBookByTag; diff --git a/backend/src/entities/VTagsSubDefault.ts b/backend/src/entities/VTagsSubDefault.ts deleted file mode 100644 index ac3cc08..0000000 --- a/backend/src/entities/VTagsSubDefault.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { DataSource, ViewColumn, ViewEntity } from 'typeorm'; -import { Book } from './Book'; -import { SuperTag } from './SuperTag'; -import { SubTag } from './SubTag'; -import { User } from './User'; - -@ViewEntity('v_tags_sub_default', { - expression: (Data: DataSource) => - Data.createQueryBuilder() - .select('sp.bookInfoId', 'bookInfoId') - .addSelect('bi.title', 'title') - .addSelect('sb.id', 'id') - .addSelect('DATE_FORMAT(sb.createdAt, "%Y-%m-%d")', 'createdAt') - .addSelect('u.nickname', 'login') - .addSelect('sb.content', 'content') - .addSelect('sp.id', 'superTagId') - .addSelect('sp.content', 'superContent') - .addSelect('sb.isPublic', 'isPublic') - .addSelect('sb.isDeleted', 'isDeleted') - .addSelect( - "CASE WHEN sb.isPublic = 1 THEN 'public' ELSE 1 'private' END", - 'visibility', - ) - .from(SuperTag, 'sp') - .innerJoin(SubTag, 'sb', 'sb.superTagId = sp.id') - .innerJoin(Book, 'bi', 'bi.id = sp.bookInfoId') - .innerJoin(User, 'u', 'u.id = sb.userId'), -}) -export class VTagsSubDefault { - @ViewColumn() - bookInfoId: number; - - @ViewColumn() - title: string; - - @ViewColumn() - id: number; - - @ViewColumn() - createdAt: string; - - @ViewColumn() - login: string; - - @ViewColumn() - content: string; - - @ViewColumn() - superTagId: number; - - @ViewColumn() - superContent: string; - - @ViewColumn() - isPublic: boolean; - - @ViewColumn() - isDeleted: boolean; - - @ViewColumn() - visibility: string; -} diff --git a/backend/src/entities/VTagsSuperDefault.ts b/backend/src/entities/VTagsSuperDefault.ts deleted file mode 100644 index d5f5255..0000000 --- a/backend/src/entities/VTagsSuperDefault.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { DataSource, ViewColumn, ViewEntity } from 'typeorm'; -import { ObjectLiteral, SelectQueryBuilder } from 'typeorm/browser'; - -@ViewEntity('v_tags_super_default', { - expression: ` - SELECT - sp1.content AS content, - ( - SELECT - COUNT(sb1.id) - FROM - sub_tag sb1 - WHERE - sb1.superTagId = sp1.id - AND sb1.isDeleted IS FALSE - AND sb1.isPublic IS TRUE) AS COUNT, - 'super' AS type, - DATE_FORMAT(sp1.createdAt, '%Y-%m-%d' - ) AS createdAt - FROM - super_tag sp1 - WHERE - sp1.isDeleted IS FALSE - AND sp1.content <> 'default' - UNION - SELECT - sb2.content AS content, - 0 AS count, - 'default' AS type, - DATE_FORMAT(sb2.createdAt, '%Y-%m-%d') AS createdAt - FROM - ( - sub_tag sb2 - JOIN super_tag sp2 - ON sb2.superTagId = sp2.id - ) - WHERE - sp2.content = 'default' - AND sb2.isDeleted IS FALSE - AND sb2.isPublic IS TRUE - AND NOT EXISTS( - SELECT - 1 - FROM super_tag sp3 - WHERE sp3.content = sb2.content - LIMIT 1 - ) - ORDER BY RAND() - `, -}) -export class VTagsSuperDefault { - @ViewColumn() - content: string; - - @ViewColumn() - count: number; - - @ViewColumn() - type: string; - - @ViewColumn() - createdAt: string; -} diff --git a/backend/src/entities/index.ts b/backend/src/entities/index.ts deleted file mode 100644 index 18a42ee..0000000 --- a/backend/src/entities/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export * from './BookCopy'; -export * from './Book'; -export * from './BookInfoSearchKeywords'; -export * from './Category'; -export * from './Lending'; -export * from './Likes'; -export * from './Reservation'; -export * from './Reviews'; -export * from './SearchKeywords'; -export * from './SearchLogs'; -export * from './SubTag'; -export * from './SuperTag'; -export * from './User'; -export * from './UserReservation'; -export * from './VHistories'; -export * from './VLending'; -export * from './VLendingForSearchUser'; -export * from './VSearchBook'; -export * from './VStock'; -export * from './VTagsSubDefault'; -export * from './VTagsSuperDefault'; -export * from './VUserLending'; -export * from './VSearchBookByTag'; diff --git a/backend/src/histories/histories.module.ts b/backend/src/histories/histories.module.ts index cf8df13..9c6aa18 100644 --- a/backend/src/histories/histories.module.ts +++ b/backend/src/histories/histories.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { HistoriesService } from 'src/histories/histories.service'; import { historiesProviders } from './histories.providers'; // Import the 'historiesProviders' variable from the correct file import { TypeOrmModule } from '@nestjs/typeorm'; -import { VHistories } from 'src/entities/VHistories'; +import { VHistories } from 'src/database/legacy_view/VHistories'; import { HistoriesController } from 'src/histories/histories.controller'; @Module({ diff --git a/backend/src/histories/histories.providers.ts b/backend/src/histories/histories.providers.ts index eef7610..ca22d83 100644 --- a/backend/src/histories/histories.providers.ts +++ b/backend/src/histories/histories.providers.ts @@ -1,5 +1,5 @@ import { DataSource } from 'typeorm'; -import { VHistories } from 'src/entities'; +import { VHistories } from 'src/database/legacy_view/VHistories'; export const historiesProviders = [ { diff --git a/backend/src/histories/histories.service.ts b/backend/src/histories/histories.service.ts index 6a9fd95..8af3192 100644 --- a/backend/src/histories/histories.service.ts +++ b/backend/src/histories/histories.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { VHistories } from 'src/entities'; +import { VHistories } from 'src/database/legacy_view/VHistories'; import { Repository } from 'typeorm'; @Injectable() diff --git a/backend/src/main.ts b/backend/src/main.ts index 450145d..ce07a49 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -2,9 +2,12 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { patchNestjsSwagger } from '@anatine/zod-nestjs'; +import { SaneLogger } from './SaneLogger'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + logger: new SaneLogger(), + }); const config = new DocumentBuilder() .setTitle('집현전 백엔드 6차') diff --git a/backend/src/users/dto/users.dto.ts b/backend/src/users/dto/users.dto.ts index 8dc5f49..cae959b 100644 --- a/backend/src/users/dto/users.dto.ts +++ b/backend/src/users/dto/users.dto.ts @@ -1,25 +1,45 @@ import { createZodDto } from '@anatine/zod-nestjs'; import { - createUsersRequestSchema, + createUserRequestSchema, + createUserResponseSchema, getAPIVersionResponseSchema, getMyUserInfoResponseSchema, + getUserRequestSchema, getUsersRequestSchema, + getUsersResponseInnerSchema, getUsersResponseSchema, + idSchema, + lendingsForSearchUser, myUpdateUsersRequestSchema, - updateUsersParamSchema, updateUsersRequestSchema, updateUsersResponseSchema, + userReservations, } from '../schema/users.schema'; +import { PaginationOptionsBaseDto } from 'src/common/dtos/page-options.dto'; -export class GetUsersRequestDto extends createZodDto(getUsersRequestSchema) {} +export function PaginationMixin< + TBase extends new (...args: any[]) => PaginationOptionsBaseDto, +>(Base: TBase) { + return class extends Base { + get skip(): number { + return (this.page - 1) * this.take; + } + }; +} -export class GetUsersResponseDto extends createZodDto(getUsersResponseSchema) {} +export class IdDto extends createZodDto(idSchema) {} + +export class GetUserRequestDto extends createZodDto(getUserRequestSchema) {} + +export class GetUserResponseDto extends createZodDto( + getUsersResponseInnerSchema, +) {} -export class CreateUsersRequestDto extends createZodDto( - createUsersRequestSchema, +export class GetUsersRequestDto extends PaginationMixin( + createZodDto(getUsersRequestSchema), ) {} -export class UpdateUsersParamDto extends createZodDto(updateUsersParamSchema) {} +export class GetUsersResponseDto extends createZodDto(getUsersResponseSchema) {} export class UpdateUsersRequestDto extends createZodDto( updateUsersRequestSchema, @@ -40,3 +60,17 @@ export class GetAPIVersionResponseDto extends createZodDto( export class GetMyUserInfoResponseDto extends createZodDto( getMyUserInfoResponseSchema, ) {} + +export class CreateUserResponseDto extends createZodDto( + createUserResponseSchema, +) {} + +export class CreateUserRequestDto extends createZodDto( + createUserRequestSchema, +) {} + +export class UserReservationsDto extends createZodDto(userReservations) {} + +export class LendingsForSearchUserDto extends createZodDto( + lendingsForSearchUser, +) {} diff --git a/backend/src/users/schema/users.schema.spec.ts b/backend/src/users/schema/users.schema.spec.ts new file mode 100644 index 0000000..f4befa8 --- /dev/null +++ b/backend/src/users/schema/users.schema.spec.ts @@ -0,0 +1,83 @@ +import { getUserRequestSchema, UserInclude } from './users.schema'; + +describe('User Schema', () => { + describe('Validation of get user request', () => { + it('should pass with valid array of includes', () => { + const validData = { + include: [UserInclude.LENDINGS, UserInclude.RESERVATIONS], + }; + const result = getUserRequestSchema.safeParse(validData); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validData); + } + }); + + it('should pass with a single valid include as string', () => { + const validData = { include: UserInclude.LENDINGS }; + const result = getUserRequestSchema.safeParse(validData); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ include: [UserInclude.LENDINGS] }); + } + }); + + it('should pass with mixed-case valid include', () => { + const validData = { include: 'LeNDinGs' }; + const result = getUserRequestSchema.safeParse(validData); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + include: [UserInclude.LENDINGS.toLowerCase()], + }); + } + }); + + it('should fail with an invalid include value', () => { + const invalidData = { include: ['invalidInclude'] }; + const result = getUserRequestSchema.safeParse(invalidData); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain( + "Invalid enum value. Expected 'lendings' | 'reservations'", + ); + } + }); + + it('should pass when include is null', () => { + const validData = { include: [] }; + const result = getUserRequestSchema.safeParse(validData); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validData); + } + }); + + it('should pass when include is undefined', () => { + const validData = { include: [] }; + const result = getUserRequestSchema.safeParse(validData); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validData); + } + }); + + it('should pass when include is empty', () => { + const validData = { include: [] }; + const result = getUserRequestSchema.safeParse(validData); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validData); + } + }); + + it('should normalize include values to lowercase', () => { + const validData = { include: ['LENDINGS', 'ReSeRvAtIoNs'] }; + const result = getUserRequestSchema.safeParse(validData); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ include: ['lendings', 'reservations'] }); + } + }); + }); +}); diff --git a/backend/src/users/schema/users.schema.ts b/backend/src/users/schema/users.schema.ts index a7ccf8c..080835c 100644 --- a/backend/src/users/schema/users.schema.ts +++ b/backend/src/users/schema/users.schema.ts @@ -1,31 +1,87 @@ import { extendApi } from '@anatine/zod-openapi'; +import { paginationOptionsSchema } from 'src/common/dtos/page-options.dto'; +import { createPageSchema } from 'src/common/dtos/page.dto'; import { z } from 'zod'; -export const getUsersRequestSchema = z.object({ - nicknameOrEmail: z.string().describe('검색할 유저의 nickname or email'), - page: z.coerce.number().int().describe('페이지').min(1), - limit: z.coerce +const getUserSearchSchema = z.object({ + search: z.string().optional().describe('검색할 유저의 nickname or email'), +}); + +export const idSchema = z.object({ + id: z.coerce.number().int().min(0), +}); + +export enum UserInclude { + LENDINGS = 'lendings', + RESERVATIONS = 'reservations', +} + +export const passwordSchema = z + .string() + .min(10, { message: 'Password must be at least 10 characters long' }) + .max(42, { message: 'Password must not exceed 42 characters' }) + .regex(/\d/, { message: 'Password must contain at least one digit' }) + .regex(/[!@#$%^&*(),.?":{}|<>]/, { + message: 'Password must contain at least one symbol', + }); + +export const fortyTwoEmailRegex = /@student\.42seoul\.kr$/; +export const emailSchema = z + .string() + .max(320) + .refine((value) => !fortyTwoEmailRegex.test(value), { + message: 'Email cannot end with @student.42seoul.kr', + }); + +export const getUserRequestSchema = z.object({ + include: z + .preprocess( + (value) => { + const arrayValue = Array.isArray(value) ? value : value ? [value] : []; + return arrayValue.map((item) => + typeof item === 'string' ? item.toLowerCase() : item, + ); + }, + z.array(z.enum([UserInclude.LENDINGS, UserInclude.RESERVATIONS])), + ) + .optional() + .default([]) + .describe('포함할 데이터'), +}); + +export const getUsersRequestSchema = getUserSearchSchema + .merge(paginationOptionsSchema) + .merge(getUserRequestSchema); + +const user = z.object({ + id: z.coerce.number().int().describe('유저 번호').min(0), + email: z.string().describe('이메일'), + nickname: z.string().describe('닉네임').nullable(), + intraId: z.coerce .number() .int() - .describe(' 한 페이지에 들어올 검색결과 수 ') - .min(1), - id: z.coerce.number().int().describe('유저의 id').min(0), + .describe('인트라 고유 번호') + .min(0) + .nullable(), + slack: z.string().describe('slack 멤버 Id').nullable(), + penaltyEndDate: z.date().describe('패널티 끝나는 날짜'), + role: z.coerce.number().int().describe('권한'), }); -const lending = z.object({ - userId: z.coerce.number(), - bookInfoId: z.coerce.number(), +export const lendingsForSearchUser = z.object({ + userId: z.coerce.number().int(), + bookInfoId: z.coerce.number().int(), lendDate: z.coerce.date(), lendingCondition: z.string(), - image: z.string(), - author: z.string(), - title: z.string(), + image: z.string().url('이미지 URL이 아닙니다.'), + author: z.string().min(1), + title: z.string().min(1), duedate: z.coerce.date(), - overDueDay: z.coerce.number(), + overDueDay: z.coerce.number().int().min(0).describe('연체된 날수'), reservedNum: z.coerce.number().min(0), }); -const VUserReservations = z.object({ +export const userReservations = z.object({ reservationId: z.coerce.number().min(0), reservedBookInfoId: z.coerce.number().min(0), reservationDate: z.coerce.date(), @@ -37,52 +93,44 @@ const VUserReservations = z.object({ userId: z.coerce.number().min(0), }); -const getUsersResponseInnerSchema = z.object({ - id: z.coerce.number().int().describe('유저 번호').min(0), - email: z.string().describe('이메일'), - nickname: z.string().describe('닉네임'), - intraId: z.coerce.number().int().describe('인트라 고유 번호').min(0), - slack: z.string().describe('slack 멤버 Id'), - penaltyEndDate: z.string().describe('패널티 끝나는 날짜'), - overDueDay: z.coerce.number().describe('현재 연체된 날수').min(0), - role: z.coerce.number().int().describe('권한'), - reservations: z.array(VUserReservations).describe('해당 유저의 예약 정보'), - lendings: z.array(lending).describe('해당 유저의 대출 정보'), -}); +export const getUsersResponseInnerSchema = z + .object({ + overDueDay: z.coerce.number().int().optional().describe('현재 연체된 날수'), + reservations: z + .array(userReservations) + .optional() + .describe('해당 유저의 예약 정보'), + lendings: z + .array(lendingsForSearchUser) + .optional() + .describe('해당 유저의 대출 정보'), + }) + .merge(user); -const getUsersResponseMetaSchema = z.object({ - totalItems: z.coerce.number().int().describe('전체 검색 결과 수'), - itemCount: z.coerce.number().int().describe('현재 페이지 검색 결과 수'), - itemsPerPage: z.coerce.number().int().describe('페이지 당 검색 결과 수'), - totalPages: z.coerce.number().int().describe('전체 결과 페이지 수'), - currentPage: z.coerce.number().int().describe('현재 페이지'), -}); +export const getUsersResponseSchema = createPageSchema( + getUsersResponseInnerSchema, +); -export const getUsersResponseSchema = z.object({ - items: z.array(getUsersResponseInnerSchema).describe('유저 정보 목록'), - meta: getUsersResponseMetaSchema.describe('유저 수와 관련된 정보'), -}); +export const getUsersResponseArraySchema = z.array(getUsersResponseInnerSchema); -export const createUsersRequestSchema = z.object({ - email: z.string().describe('이메일'), - password: z.string().describe('비밀번호'), +export const createUserRequestSchema = z.object({ + email: emailSchema, + password: passwordSchema, }); -export const updateUsersParamSchema = z.object({ - id: z.coerce.number().int().describe('변경할 유저의 id 값').min(0), -}); +export const createUserResponseSchema = z.object({ email: emailSchema }); export const updateUsersRequestSchema = z.object({ - nickname: z.string(), - intraId: z.coerce.number().int().min(0).describe('인트라 ID'), - slack: z.string().describe('slack 멤버 변수'), - role: z.coerce.number().int().describe('유저의 권한'), - penaltyEndDate: z.date().describe('패널티 끝나는 날짜'), + nickname: z.string().max(255).nullable().describe('닉네임').optional(), + intraId: z.coerce.number().int().min(0).describe('인트라 ID').optional(), + slack: z.string().nullable().describe('slack 멤버 변수').optional(), + role: z.coerce.number().int().describe('유저의 권한').optional(), + penaltyEndDate: z.date().describe('패널티 끝나는 날짜').optional(), }); export const updateUsersResponseSchema = updateUsersRequestSchema; -export const myUpdateUsersRequestSchema = createUsersRequestSchema; +export const myUpdateUsersRequestSchema = createUserRequestSchema; export const getAPIVersionResponseSchema = z.object({ version: extendApi(z.string().describe('API 버전'), { @@ -104,12 +152,15 @@ export const getMyUserInfoResponseSchema = z.object({ example: 0, }), reservations: extendApi( - z.array(VUserReservations).describe('해당 유저의 예약 정보'), + z.array(userReservations).describe('해당 유저의 예약 정보'), + { + example: [], + }, + ), + lendings: extendApi( + z.array(lendingsForSearchUser).describe('해당 유저의 대출 정보'), { example: [], }, ), - lendings: extendApi(z.array(lending).describe('해당 유저의 대출 정보'), { - example: [], - }), }); diff --git a/backend/src/users/users.controller.spec.ts b/backend/src/users/users.controller.spec.ts index 3e27c39..e9a57ca 100644 --- a/backend/src/users/users.controller.spec.ts +++ b/backend/src/users/users.controller.spec.ts @@ -1,15 +1,26 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; describe('UsersController', () => { let controller: UsersController; + let service: UsersService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], + providers: [ + { + provide: UsersService, + useValue: { + updateUser: jest.fn(), + }, + }, + ], }).compile(); controller = module.get(UsersController); + service = module.get(UsersService); }); it('should be defined', () => { diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 294a3c3..112e2e7 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,409 +1,178 @@ import { + BadRequestException, Body, Controller, Get, + InternalServerErrorException, + NotFoundException, Param, Patch, Post, Query, } from '@nestjs/common'; -import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { - CreateUsersRequestDto, - GetAPIVersionResponseDto, + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { + CreateUserRequestDto, + CreateUserResponseDto, + GetUserRequestDto, + GetUserResponseDto, GetUsersRequestDto, - MyUpddateUsersRequestDto, + GetUsersResponseDto, + IdDto, UpdateUsersRequestDto, - UpdateUsersResponseDto, } from './dto/users.dto'; +import { UsersService } from './users.service'; +import { + createUserResponseSchema, + getUsersResponseInnerSchema, + getUsersResponseSchema, + UserInclude, +} from './schema/users.schema'; +import { User } from 'src/database/entities'; +@ApiTags('users') @Controller('users') export class UsersController { - constructor() {} + constructor(private readonly usersService: UsersService) {} - @Get('search') - @ApiOperation({ - summary: 'Search users', - description: '검색할 유저의 nickname or email', - deprecated: true, - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '검색 결과를 반환한다.', - type: GetUsersRequestDto, - }) - @ApiResponse({ - status: 400, - description: '입력된 인자가 부적절합니다', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 200, - }, - }, - }, - }, - }, - }) - @ApiResponse({ - status: 500, - description: '', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - errorCode: { type: 'number', description: '에러코드', example: 1 }, - }, - }, - }, - }, - }) - async getUsersDeprecated(@Query() getUsersRequestDto: GetUsersRequestDto) {} + /** + * API 분리 GET /users/search 에 id query fineOne으로 변경 + * + * @param params + */ + @ApiOperation({ summary: '유저 retrieve' }) + @ApiOkResponse({ type: GetUserResponseDto }) + @ApiQuery({ + name: 'include', + required: false, + description: + 'lendings 와 reservations 정보를 가져올지 여부를 결정합니다. (e.g., lendings, reservations) lendings 를 가져올시 overDueDay 를 계산하여 반환합니다.', + enum: UserInclude, // This validates the query against the enum + isArray: true, // Indicates the query can be passed multiple times + example: ['lendings', 'reservations'], + }) + @Get(':id') + async findOne( + @Param() { id }: IdDto, + @Query() query: GetUserRequestDto, + ): Promise { + const user = await this.usersService.findOne(id, query.include); + if (!user) { + throw new NotFoundException(); + } + return user; + } @Get() - @ApiOperation({ - summary: 'Search users', - description: '검색할 유저의 nickname or email', - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '검색 결과를 반환한다.', - type: GetUsersRequestDto, - }) - @ApiResponse({ - status: 400, - description: '입력된 인자가 부적절합니다', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 200, - }, - }, - }, + @ApiOperation({ summary: '유저 list' }) + @ApiOkResponse({ type: GetUsersResponseDto }) + @ApiQuery({ + name: 'include', + required: false, + description: + 'lendings 와 reservations 정보를 가져올지 여부를 결정합니다. (e.g., lendings, reservations) lendings 를 가져올시 overDueDay 를 계산하여 반환합니다.', + enum: UserInclude, // This validates the query against the enum + isArray: true, // Indicates the query can be passed multiple times + example: ['lendings', 'reservations'], + }) + async findAll( + @Query() query: GetUsersRequestDto, + ): Promise { + // Fetch user data + const [users, count] = await this.usersService.findAll(query); + return { + items: users, + meta: { + itemCount: users.length, + currentPage: query.page, + itemsPerPage: query.take, + totalItems: count, + totalPages: Math.ceil(count / query.take), }, - }, - }) - @ApiResponse({ - status: 500, - description: '', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - errorCode: { type: 'number', description: '에러코드', example: 1 }, - }, - }, - }, - }, - }) - async getUsers(@Query() getUsersRequestDto: GetUsersRequestDto) {} + }; + } - @Post('create') - @ApiOperation({ - summary: 'Create users', - description: '유저를 생성한다.', - deprecated: true, - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '유저 생성 성공!', - }) - @ApiResponse({ - status: 400, - description: 'Client Error Bad Request', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 200, - }, - }, - }, - }, - }, - }) - async createUsersDeprecated( - @Body() createUsersRequestDto: CreateUsersRequestDto, - ) {} + // @Get('me') + // @ApiOperation({ summary: '내 정보 조회' }) + // async findMe( + // @Query('include') include?: string[], + // ): Promise { + // // Validate request + // const includeResult = getUserRequestSchema.safeParse({ include }); + // if (!includeResult.success) { + // throw new BadRequestException(); + // } + // const includeData = includeResult.data; - @Post() - @ApiOperation({ - summary: 'Create users', - description: '유저를 생성한다.', - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '유저 생성 성공!', - }) - @ApiResponse({ - status: 400, - description: 'Client Error Bad Request', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 200, - }, - }, - }, - }, - }, - }) - async createUsers(@Body() createUsersRequestDto: CreateUsersRequestDto) {} + // // Fetch user data + // const responseDTO = await this.usersService.findMe(includeData); - @Post('update/:id') - @ApiOperation({ - description: '유저 정보를 변경한다.', - deprecated: true, - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '유저 정보 수정 성공!', - type: UpdateUsersResponseDto, - }) - async updateUsersDeprecated( - @Param('id') id: string, - @Body() updateUsersRequestDto: UpdateUsersRequestDto, - ) {} + // const responseResult = getUsersResponseInnerSchema.safeParse(responseDTO); + // if (!responseResult.success) { + // throw new InternalServerErrorException(); + // } + // return responseResult.data; + // } - @Post(':id') - @ApiOperation({ - description: '유저 정보를 변경한다.', - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '유저 정보 수정 성공!', - type: UpdateUsersResponseDto, - }) - async updateUsers( - @Param('id') id: string, - @Body() updateUsersRequestDto: UpdateUsersRequestDto, - ) {} + @Post() + @ApiOperation({ summary: '유저 생성' }) + @ApiResponse({ status: 201, description: '유저 생성 성공' }) + async create( + @Body() requestBody: CreateUserRequestDto, + ): Promise { + const { email, password } = requestBody; + // Create user + try { + return await this.usersService.createUser(email, password); + } catch (error) { + throw error; + } + } - @Patch('myupdate') - @ApiOperation({ - description: '내 유저정보를 변경한다.', - deprecated: true, - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '유저 정보 변경 성공!', - }) - @ApiResponse({ - status: 400, - description: '들어온 인자가 없습니다..', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 200, - }, - }, - }, - }, - }, - }) - @ApiResponse({ - status: 403, - description: '수정하려는 계정이 본인의 계정이 아닙니다', - content: { - 'application/json': { - schema: { - type: 'object', - description: 'error description', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 206, - }, - }, - }, - }, - }, - }) - @ApiResponse({ - status: 409, - description: '수정하려는 값이 중복됩니다', - content: { - 'application/json': { - schema: { - type: 'object', - description: '203, 204 에러', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 204, - }, - }, - }, - }, - }, - }) - @ApiResponse({ - status: 500, - description: 'Server Error', - content: { - 'application/json': { - schema: { - type: 'object', - description: 'error description', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 1, - }, - }, - }, - }, - }, - }) - async myUpdateUsersDeprecated( - @Body() myUpddateUsersRequestDto: MyUpddateUsersRequestDto, - ) {} + @Patch(':id') + @ApiOperation({ summary: '유저 정보 수정' }) + @ApiResponse({ status: 200, description: '유저 정보 수정 성공' }) + async update( + @Param('id') { id }: IdDto, + @Query() query: UpdateUsersRequestDto, + ): Promise { + // Check if all fields are missing + const isAllMissing = Object.keys(query).length === 0; + if (isAllMissing) { + throw new BadRequestException(); + } - @Patch('/me') - @ApiOperation({ - description: '내 유저정보를 변경한다.', - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '유저 정보 변경 성공!', - }) - @ApiResponse({ - status: 400, - description: '들어온 인자가 없습니다..', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 200, - }, - }, - }, - }, - }, - }) - @ApiResponse({ - status: 403, - description: '수정하려는 계정이 본인의 계정이 아닙니다', - content: { - 'application/json': { - schema: { - type: 'object', - description: 'error description', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 206, - }, - }, - }, - }, - }, - }) - @ApiResponse({ - status: 409, - description: '수정하려는 값이 중복됩니다', - content: { - 'application/json': { - schema: { - type: 'object', - description: '203, 204 에러', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 204, - }, - }, - }, - }, - }, - }) - @ApiResponse({ - status: 500, - description: 'Server Error', - content: { - 'application/json': { - schema: { - type: 'object', - description: 'error description', - properties: { - errorCode: { - type: 'number', - description: '에러코드', - example: 1, - }, - }, - }, - }, - }, - }) - async myUpdateUsers( - @Body() myUpddateUsersRequestDto: MyUpddateUsersRequestDto, - ) {} + // Update user + try { + return await this.usersService.updateUser(id, query); + } catch (error) { + throw error; + } + } - @Get('EasterEgg') // suggesting to change this to 'version' - @ApiOperation({ - description: '집현전 개발 버전을 확인합니다.', - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '집현전 개발 버전을 반환합니다.', - type: GetAPIVersionResponseDto, - }) - async getVersion() {} + @Patch('myupdate') + @ApiOperation({ summary: '로그인된 유저 정보 수정' }) + @ApiResponse({ status: 200, description: '유저 정보 수정 성공' }) + async updateMyself(@Query() query: UpdateUsersRequestDto): Promise { + // Check if all fields are missing + const isAllMissing = Object.keys(query).length === 0; + if (isAllMissing) { + throw new BadRequestException(); + } - @Get('me') - @ApiOperation({ - description: '내 정보를 조회합니다.', - tags: ['users'], - }) - @ApiResponse({ - status: 200, - description: '내 정보를 반환합니다.', - type: UpdateUsersResponseDto, - }) - async getMyUserInfo() {} + const numId = 1; // TODO: Get user id from token + // Update user + try { + return await this.usersService.updateUser(numId, query); + // Validate response + } catch (error) { + throw error; + } + } } diff --git a/backend/src/users/users.enums.ts b/backend/src/users/users.enums.ts new file mode 100644 index 0000000..fededcd --- /dev/null +++ b/backend/src/users/users.enums.ts @@ -0,0 +1,4 @@ +export const getUserIncludes = Object.freeze({ + Lendings: 'Lendings', + Reservations: 'Reservations', +}); diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index 440ef36..af0ad14 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Lending, Reservation, User } from 'src/database/entities'; @Module({ + imports: [TypeOrmModule.forFeature([User, Lending, Reservation])], controllers: [UsersController], providers: [UsersService], }) diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 62815ba..ac567ec 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -1,18 +1,429 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { UsersService } from './users.service'; +import { + User, + VLendingForSearchUser, + UserReservation, +} from 'src/database/entities'; +import { Repository } from 'typeorm'; +import { UserInclude } from './schema/users.schema'; +import { BadRequestException } from '@nestjs/common'; +import { UpdateUsersRequestDto } from './dto/users.dto'; +import * as bcrypt from 'bcrypt'; +import { Order } from 'src/common/dtos/page-options.dto'; + +jest.mock('bcrypt'); describe('UsersService', () => { let service: UsersService; + let usersRepository: Repository; + let vLendingForSearchUserRepository: Repository; + let userReservationRepository: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService], + providers: [ + UsersService, + { + provide: getRepositoryToken(User), + useClass: Repository, + }, + { + provide: getRepositoryToken(VLendingForSearchUser), + useClass: Repository, + }, + { + provide: getRepositoryToken(UserReservation), + useClass: Repository, + }, + ], }).compile(); service = module.get(UsersService); + usersRepository = module.get>(getRepositoryToken(User)); + vLendingForSearchUserRepository = module.get< + Repository + >(getRepositoryToken(VLendingForSearchUser)); + userReservationRepository = module.get>( + getRepositoryToken(UserReservation), + ); }); - it('should be defined', () => { - expect(service).toBeDefined(); + describe('findOne', () => { + it('should return null if user is not found', async () => { + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(null); + + const result = await service.findOne(1, []); + expect(result).toBeNull(); + }); + + it('should return user without includes if includes is empty', async () => { + const user = { + id: 1, + email: 'test@example.com', + nickname: 'test', + } as User; + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(user); + + const result = await service.findOne(1, []); + expect(result).toEqual({ + id: 1, + email: 'test@example.com', + nickname: 'test', + }); + }); + + it('should return user with lendings and reservations if includes are provided', async () => { + const user = { + id: 1, + email: 'test@example.com', + nickname: 'test', + } as User; + const lendings = [ + { userId: 1, overDueDay: 5 }, + ] as VLendingForSearchUser[]; + const reservations = [{ userId: 1 }] as UserReservation[]; + + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(user); + jest + .spyOn(vLendingForSearchUserRepository, 'find') + .mockResolvedValue(lendings); + jest + .spyOn(userReservationRepository, 'find') + .mockResolvedValue(reservations); + + const result = await service.findOne(1, ['lendings', 'reservations']); + expect(result).toEqual({ + id: 1, + email: 'test@example.com', + nickname: 'test', + lendings, + overDueDay: 5, + reservations, + }); + }); + + it('should return user with only lendings if only lendings are included', async () => { + const user = { + id: 1, + email: 'test@example.com', + nickname: 'test', + } as User; + const lendings = [ + { userId: 1, overDueDay: 5 }, + ] as VLendingForSearchUser[]; + + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(user); + jest + .spyOn(vLendingForSearchUserRepository, 'find') + .mockResolvedValue(lendings); + jest.spyOn(userReservationRepository, 'find').mockResolvedValue([]); + + const result = await service.findOne(1, ['lendings']); + expect(result).toEqual({ + id: 1, + email: 'test@example.com', + nickname: 'test', + lendings, + overDueDay: 5, + }); + }); + + it('should return user with only reservations if only reservations are included', async () => { + const user = { + id: 1, + email: 'test@example.com', + nickname: 'test', + } as User; + const reservations = [{ userId: 1 }] as UserReservation[]; + + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(user); + jest.spyOn(vLendingForSearchUserRepository, 'find').mockResolvedValue([]); + jest + .spyOn(userReservationRepository, 'find') + .mockResolvedValue(reservations); + + const result = await service.findOne(1, ['reservations']); + expect(result).toEqual({ + id: 1, + email: 'test@example.com', + nickname: 'test', + reservations, + }); + }); + + describe('findAll', () => { + it('should return users without includes if includes is empty', async () => { + const query = { + search: '', + page: 1, + take: 10, + include: [], + order: Order.ASC, + skip: 0, + }; + const users = [ + { id: 1, email: 'test1@example.com', nickname: 'test1' }, + { id: 2, email: 'test2@example.com', nickname: 'test2' }, + ] as User[]; + const total = 2; + + jest + .spyOn(usersRepository, 'findAndCount') + .mockResolvedValue([users, total]); + + const result = await service.findAll(query); + expect(result).toEqual([ + [ + { id: 1, email: 'test1@example.com', nickname: 'test1' }, + { id: 2, email: 'test2@example.com', nickname: 'test2' }, + ], + total, + ]); + }); + + it('should return users with lendings and reservations if includes are provided', async () => { + const query = { + search: '', + page: 1, + take: 10, + include: [UserInclude.LENDINGS, UserInclude.RESERVATIONS], + order: Order.ASC, + skip: 0, + }; + const users = [ + { id: 1, email: 'test1@example.com', nickname: 'test1' }, + { id: 2, email: 'test2@example.com', nickname: 'test2' }, + ] as User[]; + const total = 2; + const lendings = [ + { userId: 1, overDueDay: 5 }, + ] as VLendingForSearchUser[]; + const reservations = [{ userId: 1 }] as UserReservation[]; + + jest + .spyOn(usersRepository, 'findAndCount') + .mockResolvedValue([users, total]); + jest.spyOn(service, 'getUserLendings').mockResolvedValue(lendings); + jest + .spyOn(service, 'getUserReservations') + .mockResolvedValue(reservations); + + const result = await service.findAll(query); + expect(result).toEqual([ + [ + { + id: 1, + email: 'test1@example.com', + nickname: 'test1', + lendings, + reservations, + overDueDay: 5, + }, + { + id: 2, + email: 'test2@example.com', + nickname: 'test2', + overDueDay: 0, + lendings: [], + reservations: [], + }, + ], + total, + ]); + }); + + it('should return users with only lendings if only lendings are included', async () => { + const query = { + search: '', + page: 1, + take: 10, + include: [UserInclude.LENDINGS], + order: Order.ASC, + skip: 0, + }; + const users = [ + { id: 1, email: 'test1@example.com', nickname: 'test1' }, + { id: 2, email: 'test2@example.com', nickname: 'test2' }, + ] as User[]; + const total = 2; + const lendings = [ + { userId: 1, overDueDay: 5 }, + ] as VLendingForSearchUser[]; + + jest + .spyOn(usersRepository, 'findAndCount') + .mockResolvedValue([users, total]); + jest.spyOn(service, 'getUserLendings').mockResolvedValue(lendings); + jest.spyOn(service, 'getUserReservations').mockResolvedValue([]); + + const result = await service.findAll(query); + expect(result).toEqual([ + [ + { + id: 1, + email: 'test1@example.com', + nickname: 'test1', + lendings, + overDueDay: 5, + }, + { + id: 2, + email: 'test2@example.com', + nickname: 'test2', + lendings: [], + overDueDay: 0, + }, + ], + total, + ]); + }); + + it('should return users with only reservations if only reservations are included', async () => { + const query = { + search: '', + page: 1, + take: 10, + include: [UserInclude.RESERVATIONS], + order: Order.ASC, + skip: 0, + }; + const users = [ + { id: 1, email: 'test1@example.com', nickname: 'test1' }, + { id: 2, email: 'test2@example.com', nickname: 'test2' }, + ] as User[]; + const total = 2; + const reservations = [{ userId: 1 }] as UserReservation[]; + + jest + .spyOn(usersRepository, 'findAndCount') + .mockResolvedValue([users, total]); + jest.spyOn(service, 'getUserLendings').mockResolvedValue([]); + jest + .spyOn(service, 'getUserReservations') + .mockResolvedValue(reservations); + + const result = await service.findAll(query); + expect(result).toEqual([ + [ + { + id: 1, + email: 'test1@example.com', + nickname: 'test1', + reservations, + }, + { + id: 2, + email: 'test2@example.com', + nickname: 'test2', + reservations: [], + }, + ], + total, + ]); + }); + + it('should return users matching the search criteria', async () => { + const query = { + search: 'test1', + page: 1, + take: 10, + include: [], + order: Order.ASC, + skip: 0, + }; + const users = [ + { id: 1, email: 'test1@example.com', nickname: 'test1' }, + ] as User[]; + const total = 1; + + jest + .spyOn(usersRepository, 'findAndCount') + .mockResolvedValue([users, total]); + + const result = await service.findAll(query); + expect(result).toEqual([ + [{ id: 1, email: 'test1@example.com', nickname: 'test1' }], + total, + ]); + }); + + describe('createUser', () => { + it('should throw BadRequestException if email already exists', async () => { + const email = 'test@example.com'; + const password = 'password'; + const existingUser = { id: 1, email } as User; + + jest + .spyOn(usersRepository, 'findOne') + .mockResolvedValue(existingUser); + + await expect(service.createUser(email, password)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should create a new user if email does not exist', async () => { + const email = 'test@example.com'; + const password = 'password'; + const hashedPassword = 'hashedPassword'; + const newUser = { id: 1, email, password: hashedPassword } as User; + + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(null); + (bcrypt.hash as jest.Mock).mockResolvedValue(hashedPassword); + jest.spyOn(usersRepository, 'create').mockReturnValue(newUser); + jest.spyOn(usersRepository, 'save').mockResolvedValue(newUser); + + const result = await service.createUser(email, password); + expect(result).toEqual(newUser); + expect(bcrypt.hash).toHaveBeenCalledWith(password, 10); + expect(usersRepository.create).toHaveBeenCalledWith({ + email, + password: hashedPassword, + }); + expect(usersRepository.save).toHaveBeenCalledWith(newUser); + }); + + describe('updateUser', () => { + it('should throw BadRequestException if user is not found', async () => { + const id = 1; + const requestData = { + email: 'new@example.com', + } as UpdateUsersRequestDto; + + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(null); + + await expect(service.updateUser(id, requestData)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should update and return the user if user is found', async () => { + const id = 1; + const requestData = { + email: 'new@example.com', + } as UpdateUsersRequestDto; + const user = { id, email: 'old@example.com' } as User; + const updatedUser = { ...user, ...requestData } as User; + + jest.spyOn(usersRepository, 'findOne').mockResolvedValue(user); + jest.spyOn(usersRepository, 'merge').mockReturnValue(updatedUser); + jest.spyOn(usersRepository, 'save').mockResolvedValue(updatedUser); + + const result = await service.updateUser(id, requestData); + expect(result).toEqual(updatedUser); + expect(usersRepository.findOne).toHaveBeenCalledWith({ + where: { id }, + }); + expect(usersRepository.merge).toHaveBeenCalledWith( + user, + requestData, + ); + expect(usersRepository.save).toHaveBeenCalledWith(updatedUser); + }); + }); + }); + }); }); }); diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index ef0d82d..f44a766 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -1,4 +1,248 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Lending, Reservation, User } from 'src/database/entities'; +import { Like, Repository } from 'typeorm'; +import { + GetUserResponseDto, + GetUsersRequestDto, + LendingsForSearchUserDto, + UpdateUsersRequestDto, + UserReservationsDto, +} from './dto/users.dto'; +import { getUserIncludes } from './users.enums'; +import { isStringInArrayCaseInsensitive } from 'src/common/utils/utils'; +import * as bcrypt from 'bcrypt'; +import { UserInclude } from './schema/users.schema'; @Injectable() -export class UsersService {} +export class UsersService { + constructor( + @InjectRepository(User) + private readonly usersRepository: Repository, + @InjectRepository(Lending) + private readonly lendingRepository: Repository, + @InjectRepository(Reservation) + private readonly reservationRepository: Repository, + ) {} + + /** + * + * @param id 유저 id + * @param includes optional data to include [lendings, reservations] + * @returns if user found, return GetUserResponseDto, else return null + */ + async findOne( + id: number, + includes: string[], + ): Promise { + const user = await this.usersRepository.findOne({ where: { id } }); + if (!user) return null; + const { reservations, lendings, ...rest } = user; + if (!includes) return rest; + + const [vLendings, vReservations] = await Promise.all([ + includes.includes('lendings') + ? this.findLendingForSearchUser([id]) + : ([] as LendingsForSearchUserDto[]), + includes.includes('reservations') + ? this.findActiveReservations([id]) + : ([] as UserReservationsDto[]), + ]); + + const result: GetUserResponseDto = { ...rest }; + + if (includes.includes(UserInclude.LENDINGS)) { + const overDueDay = this.getOverDueDay(vLendings); + result.lendings = vLendings; + result.overDueDay = overDueDay; + } + + if (includes.includes(UserInclude.RESERVATIONS)) { + result.reservations = vReservations; + } + + return result; + } + + // private async getLendings(userId: number) { + + // } + + private getOverDueDay(lendings: LendingsForSearchUserDto[]): number { + if (!lendings) return 0; + return lendings.reduce((acc, cur) => (acc += cur.overDueDay), 0); + } + + async findAll( + query: GetUsersRequestDto, + ): Promise<[GetUserResponseDto[], number]> { + const { search, order, take, include } = query; + + const [users, total] = await this.usersRepository.findAndCount({ + where: search + ? { nickname: Like(`%${search}%`), email: Like(`%${search}%`) } + : {}, + take: take, + skip: query.skip, + order: { id: order }, + }); + + const responseDto = users.map((user) => { + const { reservations, lendings, ...rest } = user; + return rest; + }); + + if (!include) { + return [responseDto, total]; + } + + const userIds = users.map((user) => user.id); + + const [lendings, reservations] = await Promise.all([ + isStringInArrayCaseInsensitive(getUserIncludes.Lendings, include) + ? this.findLendingForSearchUser(userIds) + : Promise.resolve([]), + isStringInArrayCaseInsensitive(getUserIncludes.Reservations, include) + ? this.getUserReservations(userIds) + : Promise.resolve([]), + ]); + + const lendingMap = Map.groupBy(lendings, (lendings) => lendings.userId); + const reservationMap = Map.groupBy( + reservations, + (reservations) => reservations.userId, + ); + + const updatedResponseDto = responseDto.map((userDto) => { + let result: GetUserResponseDto = { ...userDto }; + const userId = userDto.id; + if (lendings.length) { + result.lendings = lendingMap.get(userId) || []; + result.overDueDay = this.getOverDueDay(result.lendings); + } + if (reservations.length) { + result.reservations = reservationMap.get(userId) || []; + } + return result; + }); + + return [updatedResponseDto, total]; + } + + async getUserReservations(userIds: number[]): Promise { + // return await this.userReservationRepository.find({ + // where: { userId: In(userIds) }, + // }); + return await this.findActiveReservations(userIds); + } + + async createUser(email: string, password: string): Promise { + const existingUser = await this.usersRepository.findOne({ + where: { email }, + }); + if (existingUser) { + throw new BadRequestException('Duplicate email'); + } + + let hashedPassword: string; + hashedPassword = await bcrypt.hash(password, 10); + + const user = this.usersRepository.create({ + email, + password: hashedPassword, + }); + return await this.usersRepository.save(user); + } + + async updateUser( + id: number, + requestData: UpdateUsersRequestDto, + ): Promise { + const user = await this.usersRepository.findOne({ where: { id } }); + if (!user) { + throw new BadRequestException('User not found'); + } + const updatedUser = this.usersRepository.merge(user, requestData); + return await this.usersRepository.save(updatedUser); + } + + async findLendingForSearchUser(userIds: number[]) { + const lendings = await this.findActiveLendings(userIds); + + const results = await Promise.all( + lendings.map(async (lending) => { + const currentDate = new Date(); + const dueDate = new Date(lending.duedate); + const overDueDay = + currentDate > dueDate + ? Math.floor( + (currentDate.getTime() - dueDate.getTime()) / + (1000 * 60 * 60 * 24), + ) + : 0; + + const reservedNum = await this.reservationRepository.count({ + where: { bookInfoId: lending.bookInfoId, status: 0 }, + }); + + return { + ...lending, + overDueDay, + reservedNum, + }; + }), + ); + + return results; + } + + async findActiveLendings( + userIds: number[], + ): Promise { + return this.lendingRepository + .createQueryBuilder('l') + .select('u.id', 'userId') + .addSelect('bi.id', 'bookInfoId') + .addSelect('l.createdAt', 'lendDate') + .addSelect('l.lendingCondition', 'lendingCondition') + .addSelect('bi.image', 'image') + .addSelect('bi.author', 'author') + .addSelect('bi.title', 'title') + .addSelect('DATE_ADD(l.createdAt, INTERVAL 14 DAY)', 'duedate') + .innerJoin('user', 'u', 'l.userId = u.id') + .leftJoin('book', 'b', 'l.bookId = b.id') + .leftJoin('book_info', 'bi', 'b.infoid = bi.id') + .where('l.returnedAt IS NULL') + .where('u.id IN (:...userIds)', { userIds }) + .getRawMany(); + } + + async findActiveReservations( + userIds: number[], + ): Promise { + const reservations = await this.reservationRepository + .createQueryBuilder('r') + .select('r.id', 'reservationId') + .addSelect('r.bookInfoId', 'reservedBookInfoId') + .addSelect('r.createdAt', 'reservationDate') + .addSelect('r.endAt', 'endAt') + .addSelect('bi.title', 'title') + .addSelect('bi.author', 'author') + .addSelect('bi.image', 'image') + .addSelect('r.userId', 'userId') + .leftJoin('book_info', 'bi', 'r.bookInfoId = bi.id') + .where('r.status = 0') + .andWhere('r.userId IN (:...userIds)', { userIds }) + .getRawMany(); + + // Perform ranking calculation manually in JavaScript + return reservations.map((reservation) => { + const ranking = reservations.filter( + (r) => + r.bookInfoId === reservation.bookInfoId && + r.createdAt <= reservation.createdAt, + ).length; + return { ...reservation, ranking }; + }); + } +} diff --git a/package.json b/package.json index 605553f..9e9ab02 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,5 @@ "keywords": [], "author": "", "license": "ISC", - "dependencies": {}, "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1" }