diff --git a/.gitignore b/.gitignore index 907865ae9..8e4cd655f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ /generated/prisma .env +dist/ +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index aa2646fdf..016412f5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,10 @@ "license": "ISC", "dependencies": { "@prisma/client": "^6.13.0", + "@types/multer": "^2.0.0", "axios": "^1.10.0", "bcrypt": "^6.0.0", + "class-validator": "^0.14.2", "cloudinary": "^1.41.3", "cookie-parser": "^1.4.7", "express": "^5.1.0", @@ -23,13 +25,34 @@ }, "devDependencies": { "@eslint/js": "^9.31.0", + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.9", + "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.3.1", "eslint": "^9.31.0", "eslint-plugin-react": "^7.37.5", "globals": "^16.3.0", + "nodemon": "^3.1.10", "prettier": "^3.6.2", + "ts-node": "^10.9.2", + "typescript": "^5.9.2", "typescript-eslint": "^8.37.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -250,6 +273,34 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -373,6 +424,73 @@ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", "license": "MIT" }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -380,6 +498,35 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -387,6 +534,87 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "24.3.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", + "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.3", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", + "integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.41.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", @@ -694,6 +922,19 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -727,12 +968,33 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/append-field": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -942,6 +1204,19 @@ "node": ">= 18" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -1145,6 +1420,17 @@ "consola": "^3.2.3" } }, + "node_modules/class-validator": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.2.tgz", + "integrity": "sha512-3kMVRF2io8N8pY1IFIXlho9r8IPUUIfHe2hYVtiebvAzU2XeQFXTv+XI4WX+TnXmtwXMDcjngcpkiPM0O9PvLw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.11.8", + "libphonenumber-js": "^1.11.1", + "validator": "^13.9.0" + } + }, "node_modules/cloudinary": { "version": "1.41.3", "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-1.41.3.tgz", @@ -1307,6 +1593,13 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1475,6 +1768,16 @@ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -2249,6 +2552,21 @@ "node": ">= 0.8" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2561,6 +2879,13 @@ "node": ">= 4" } }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2672,6 +2997,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -3192,6 +3530,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.17", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.17.tgz", + "integrity": "sha512-bsxi8FoceAYR/bjHcLYc2ShJ/aVAzo5jaxAYiMHF0BD+NTp47405CGuPNKYpw+lHadN9k/ClFGc9X5vaZswIrA==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3276,6 +3620,13 @@ "loose-envify": "cli.js" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3503,6 +3854,132 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/nodemon/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nypm": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz", @@ -3909,6 +4386,13 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -4451,6 +4935,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -4656,6 +5166,16 @@ "node": ">=0.6" } }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -4669,6 +5189,50 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4786,7 +5350,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4838,6 +5401,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4863,6 +5439,22 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/validator": { + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -5002,6 +5594,16 @@ "node": ">=0.4" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7dd212d72..18e8f66af 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "main": "src/main.js", "type": "module", "scripts": { - "start": "node src/main.js", + "build": "tsc", + "start": "node dist/main.js", + "dev": "nodemon --watch 'src/**/*.ts' --exec 'node --loader ts-node/esm' src/main.ts", "test": "echo \"Error: no test specified\" && exit 1", "lint": "eslint .", "format": "prettier --write ." @@ -15,8 +17,10 @@ "description": "", "dependencies": { "@prisma/client": "^6.13.0", + "@types/multer": "^2.0.0", "axios": "^1.10.0", "bcrypt": "^6.0.0", + "class-validator": "^0.14.2", "cloudinary": "^1.41.3", "cookie-parser": "^1.4.7", "express": "^5.1.0", @@ -28,10 +32,18 @@ }, "devDependencies": { "@eslint/js": "^9.31.0", + "@types/bcrypt": "^6.0.0", + "@types/cookie-parser": "^1.4.9", + "@types/express": "^5.0.3", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.3.1", "eslint": "^9.31.0", "eslint-plugin-react": "^7.37.5", "globals": "^16.3.0", + "nodemon": "^3.1.10", "prettier": "^3.6.2", + "ts-node": "^10.9.2", + "typescript": "^5.9.2", "typescript-eslint": "^8.37.0" }, "prisma": { diff --git a/prisma/migrations/20250916014222_user_created_at_updated_at/migration.sql b/prisma/migrations/20250916014222_user_created_at_updated_at/migration.sql new file mode 100644 index 000000000..790c3fae1 --- /dev/null +++ b/prisma/migrations/20250916014222_user_created_at_updated_at/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - You are about to drop the column `createAt` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `updateAt` on the `User` table. All the data in the column will be lost. + - Added the required column `updatedAt` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "public"."User" DROP COLUMN "createAt", +DROP COLUMN "updateAt", +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 122deafdb..973d3a77e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,8 +20,8 @@ model User { image String? password String refreshToken String? - createAt DateTime @default(now()) - updateAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt products Product[] articles Article[] Comment Comment[] diff --git a/prisma/seed.js b/prisma/seed.js index e819605e6..74d613f87 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -2,6 +2,15 @@ import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { + // User 생성 + const user = await prisma.user.create({ + data: { + email: "yonjin.oh@gmail.com", + nickname: "오연진", + password: "qwer1234!", + }, + }); + // Product 하나 생성 const product = await prisma.product.create({ data: { @@ -9,6 +18,7 @@ async function main() { description: "상품 설명", price: 19900, tags: ["전자제품", "할인"], + userId: user.id, }, }); @@ -17,6 +27,7 @@ async function main() { data: { title: "자유게시판 글", content: "어쩌구 저쩌구", + userId: user.id, }, }); @@ -26,10 +37,12 @@ async function main() { { content: "상품 댓글 어쩌구", productId: product.id, + userId: user.id, }, { content: "게시글 댓글 어쩌구", articleId: article.id, + userId: user.id, }, ], }); diff --git a/src/api/controllers/ArticleController.js b/src/api/controllers/ArticleController.js deleted file mode 100644 index 670ce0ef6..000000000 --- a/src/api/controllers/ArticleController.js +++ /dev/null @@ -1,92 +0,0 @@ -import ArticleService from "../services/ArticleService.js"; -import jwt from "jsonwebtoken"; - -const JWT_SECRET = process.env.JWT_SECRET; - -const ArticleController = { - async createArticle(req, res, next) { - try { - const { id: userId } = req.user; - const { title, content } = req.body; - if (!title || !content) { - return res.status(400).send("제목과 게시글을 입력해주세요."); - } - const articleData = { title, content }; - const newArticle = await ArticleService.createArticle( - articleData, - userId - ); - - res.status(201).json(newArticle); - } catch (err) { - next(err); - } - }, - - async findUniqueArticle(req, res, next) { - try { - const { id } = req.params; - const articleId = Number(id); - - let userId = null; - const token = req.cookies.accessToken; - - if (token) { - try { - const decoded = jwt.verify(token, JWT_SECRET); - userId = decoded.userId; - } catch (err) { - console.error("토큰 검증 오류:", err); - } - } - const article = await ArticleService.findUniqueArticle(articleId, userId); - res.status(200).json(article); - } catch (err) { - next(err); - } - }, - - async updateArticle(req, res, next) { - try { - const { id: userId } = req.user; - const { id } = req.params; - const updateData = req.body; - const article = await ArticleService.updateArticle( - Number(id), - updateData, - userId - ); - res.status(201).json(article); - } catch (err) { - next(err); - } - }, - - async deleteArticle(req, res, next) { - try { - const { id: userId } = req.user; - const { id } = req.params; - await ArticleService.deleteArticle(Number(id), userId); - res.status(201).json({ success: "상품 삭제 성공" }); - } catch (err) { - next(err); - } - }, - - async findManyArticle(req, res, next) { - try { - const { offset = 0, limit = 10, order = "recent", keyword } = req.query; - const articles = await ArticleService.findManyArticle({ - offset, - limit, - order, - keyword, - }); - res.status(200).json(articles); - } catch (err) { - next(err); - } - }, -}; - -export default ArticleController; diff --git a/src/api/controllers/AuthController.js b/src/api/controllers/AuthController.js deleted file mode 100644 index 1d22364ed..000000000 --- a/src/api/controllers/AuthController.js +++ /dev/null @@ -1,55 +0,0 @@ -import AuthService from "../services/AuthService.js"; - -const AuthController = { - async signup(req, res, next) { - try { - const newUser = await AuthService.signup(req.body); - res.status(201).json(newUser); - } catch (err) { - next(err); - } - }, - - async login(req, res, next) { - try { - const { user, accessToken, refreshToken } = await AuthService.login(req.body); - - res.cookie("refreshToken", refreshToken, { - httpOnly: true, - maxAge: 7 * 24 * 60 * 60 * 1000, - }); - - res.cookie("accessToken", accessToken, { - maxAge: 1 * 60 * 60 * 1000, - }); - - res.status(200).json({ user, accessToken, refreshToken }); - } catch (err) { - next(err); - } - }, - - async refreshToken(req, res, next) { - try { - const { accessToken, refreshToken: newRefreshToken } = await AuthService.refreshAccessToken( - req.cookies.refreshToken - ); - - // 쿠키에 토큰 저장 - res.cookie("refreshToken", newRefreshToken, { - httpOnly: true, - maxAge: 7 * 24 * 60 * 60 * 1000, - }); - - res.cookie("accessToken", accessToken, { - maxAge: 1 * 60 * 60 * 1000, - }); - - res.status(200).json({ accessToken }); - } catch (err) { - next(err); - } - }, -}; - -export default AuthController; diff --git a/src/api/controllers/CommentController.js b/src/api/controllers/CommentController.js deleted file mode 100644 index 37e942538..000000000 --- a/src/api/controllers/CommentController.js +++ /dev/null @@ -1,92 +0,0 @@ -import CommentService from "../services/CommentService.js"; - -const CommentController = { - async createComment(req, res, next) { - try { - const { id: userId } = req.user; - const { content, productId, articleId } = req.body; - - if (!content || (!productId && !articleId)) { - return res.status(400).json({ error: "댓글과 게시글 ID는 필수값" }); - } - - if (productId && articleId) { - return res - .status(400) - .json({ error: "productId 혹은 articleId 둘 중 하나만 있어야 함" }); - } - - const newComment = await CommentService.createComment({ - content, - productId, - articleId, - userId, - }); - res.status(201).json(newComment); - } catch (err) { - if (err.code === "P2003") { - const target = req.body.productId ? "상품" : "게시글"; - return res.status(404).json({ error: `존재하지 않는 ${target}` }); - } - next(err); - } - }, - - async updateComment(req, res, next) { - try { - const { id: userId } = req.user; - const { id } = req.params; - const updateData = req.body; - - const comment = await CommentService.updateComment( - Number(id), - updateData, - userId - ); - res.status(201).json(comment, next); - } catch (err) { - if (err.code === "P2025") { - res.status(404).json({ error: "해당 댓글이 존재하지 않음" }); - } - next(err); - } - }, - - async deleteComment(req, res, next) { - try { - const { id: userId } = req.user; - const { id } = req.params; - await CommentService.deleteComment(Number(id), userId); - res.status(201).json({ success: "댓글 삭제" }); - } catch (err) { - if (err.code === "P2025") { - res.status(404).json({ error: "해당 댓글이 존재하지 않음" }); - } - next(err); - } - }, - async findManyComment(req, res, next) { - try { - const { productId, articleId, cursor, limit = 10 } = req.query; - - if (productId && articleId) { - return res - .status(400) - .json({ error: "productId 혹은 articleId 둘 중 하나만 있어야 함" }); - } - - const comments = await CommentService.findManyComment({ - productId: Number(productId), - articleId: Number(articleId), - cursor, - limit, - }); - - res.status(200).json(comments); - } catch (err) { - next(err); - } - }, -}; - -export default CommentController; diff --git a/src/api/controllers/LikeController.js b/src/api/controllers/LikeController.js deleted file mode 100644 index eaf66a9ad..000000000 --- a/src/api/controllers/LikeController.js +++ /dev/null @@ -1,18 +0,0 @@ -import LikeService from "../services/LikeService.js"; - -const LikeController = { - async toggleLike(req, res, next) { - try { - const { id: userId } = req.user; - const { type, id } = req.params; - const contentId = Number(id); - - const result = await LikeService.toggleLike(userId, type, contentId); - res.status(200).json(result); - } catch (err) { - next(err); - } - }, -}; - -export default LikeController; diff --git a/src/api/controllers/MypageController.js b/src/api/controllers/MypageController.js deleted file mode 100644 index e14e3be10..000000000 --- a/src/api/controllers/MypageController.js +++ /dev/null @@ -1,61 +0,0 @@ -import MypageService from "../services/MypageService.js"; - -const MypageController = { - async getUser(req, res, next) { - try { - const { id: userId } = req.user; - - const user = await MypageService.getUser(userId); - res.status(200).json(user); - } catch (err) { - next(err); - } - }, - - async updateUser(req, res, next) { - try { - const { id: userId } = req.user; - const updateData = req.body; - const updatedUser = await MypageService.updateUser(userId, updateData); - res.status(201).json(updatedUser); - } catch (err) { - next(err); - } - }, - - async updatePassword(req, res, next) { - try { - const { id: userId } = req.user; - const { oldPassword, newPassword } = req.body; - - await MypageService.updatePassword(userId, oldPassword, newPassword); - res.status(201).json("비밀번호 변경이 완료되었습니다."); - } catch (err) { - next(err); - } - }, - - async getProducts(req, res, next) { - try { - const { id: userId } = req.user; - - const products = await MypageService.getProducts(userId); - res.status(200).json(products); - } catch (err) { - next(err); - } - }, - - async getLikeProducts(req, res, next) { - try { - const { id: userId } = req.user; - - const products = await MypageService.getLikeProducts(userId); - res.status(200).json(products); - } catch (err) { - next(err); - } - }, -}; - -export default MypageController; diff --git a/src/api/controllers/ProductController.js b/src/api/controllers/ProductController.js deleted file mode 100644 index 3f2651b85..000000000 --- a/src/api/controllers/ProductController.js +++ /dev/null @@ -1,96 +0,0 @@ -import ProductService from "../services/ProductService.js"; -import jwt from "jsonwebtoken"; - -const JWT_SECRET = process.env.JWT_SECRET; - -const ProductController = { - async createProduct(req, res, next) { - try { - const { id: userId } = req.user; - const { name, description, price, tags } = req.body; - const productData = { name, description, price, tags }; - const newProduct = await ProductService.createProduct( - productData, - userId - ); - - res.status(201).json(newProduct); - } catch (err) { - next(err); - } - }, - - async findUniqueProduct(req, res, next) { - try { - //throw new Error("🔥에러 핸들러 테스트"); - const { id } = req.params; - const productId = Number(id); - - let userId = null; - const token = req.cookies.accessToken; - - if (token) { - try { - const decoded = jwt.verify(token, JWT_SECRET); - userId = decoded.userId; - } catch (err) { - console.error("토큰 검증 오류:", err); - } - } - - const product = await ProductService.findUniqueProduct(productId, userId); - res.status(200).json(product); - } catch (err) { - next(err); - } - }, - - async patchProduct(req, res, next) { - try { - const { id } = req.params; - const { id: userId } = req.user; - const updateData = req.body; - - const product = await ProductService.patchProduct( - Number(id), - updateData, - userId - ); - if (!product) { - return res.status(404).json({ error: "수정할 상품이 없음" }); - } - res.status(201).json(product); - } catch (err) { - next(err); - } - }, - - async deleteProduct(req, res, next) { - try { - const { id } = req.params; - const { id: userId } = req.user; - await ProductService.deleteProduct(Number(id), userId); - - res.status(201).json({ success: "상품 삭제 성공" }); - } catch (err) { - next(err); - } - }, - - async findManyProduct(req, res, next) { - try { - const { offset = 0, limit = 10, order = "recent", keyword } = req.query; - const products = await ProductService.findManyProduct({ - offset, - limit, - order, - keyword, - }); - res.status(200).json(products); - } catch (err) { - next(err); - } - }, -}; - -export default ProductController; diff --git a/src/api/controllers/article.controller.ts b/src/api/controllers/article.controller.ts new file mode 100644 index 000000000..4d0406c56 --- /dev/null +++ b/src/api/controllers/article.controller.ts @@ -0,0 +1,102 @@ +import ArticleService from "../services/article/article.service.js"; +import jwt from "jsonwebtoken"; +import type { Request, Response, NextFunction } from "express"; +import { ACCESS_TOKEN_SECRET } from "../libs/constants.js"; +import type { CustomError } from "src/api/types/error.js"; +import type { RequestWithDto } from "../types/express.d.ts"; +import type { ArticleDto } from "../services/article/article.dto.js"; +import { FindManyParamsDto } from "../types/dto.js"; + +const ArticleController = { + async createArticle(req: RequestWithDto, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const articleDto = req.body; + const newArticle = await ArticleService.createArticle(articleDto, userId); + + res.status(201).json(newArticle); + } catch (err) { + next(err); + } + }, + + async findUniqueArticle(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const articleId = Number(id); + + let userId = null; + const token = req.cookies.accessToken; + + if (token) { + try { + const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET); + if (typeof decoded === "string" || !decoded.id) { + const error: CustomError = new Error("유효하지 않은 Token입니다."); + error.statusCode = 403; + throw error; + } + userId = decoded.id; + } catch (err) { + console.error("토큰 검증 오류:", err); + } + } + const article = await ArticleService.findUniqueArticle(articleId, userId); + res.status(200).json(article); + } catch (err) { + next(err); + } + }, + + async updateArticle(req: RequestWithDto, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const { id } = req.params; + const articleDto = req.body; + + const article = await ArticleService.updateArticle(Number(id), articleDto, userId); + res.status(200).json(article); + } catch (err) { + next(err); + } + }, + + async deleteArticle(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const { id } = req.params; + await ArticleService.deleteArticle(Number(id), userId); + res.status(200).json({ success: "상품 삭제 성공" }); + } catch (err) { + next(err); + } + }, + + async findManyArticle(req: Request, res: Response, next: NextFunction) { + try { + const params = FindManyParamsDto.from(req.query); + + const articles = await ArticleService.findManyArticle(params); + res.status(200).json(articles); + } catch (err) { + next(err); + } + }, +}; + +export default ArticleController; diff --git a/src/api/controllers/auth.controller.ts b/src/api/controllers/auth.controller.ts new file mode 100644 index 000000000..2b2bc2143 --- /dev/null +++ b/src/api/controllers/auth.controller.ts @@ -0,0 +1,87 @@ +import AuthService from "../services/auth/auth.service.js"; +import type { Request, Response, NextFunction } from "express"; +import type { SignupDto, LoginDto } from "../services/auth/auth.dto.js"; +import type { RequestWithDto } from "../types/express.d.ts"; + +const AuthController = { + async signup(req: RequestWithDto, res: Response, next: NextFunction) { + try { + const signupDto = req.body; + const newUser = await AuthService.signup(signupDto); + + res.status(201).json(newUser); + } catch (err) { + next(err); + } + }, + + async login(req: RequestWithDto, res: Response, next: NextFunction) { + try { + const loginDto = req.body; + const { userWithoutPassword: user, accessToken, refreshToken } = await AuthService.login(loginDto); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + sameSite: "lax", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + res.cookie("accessToken", accessToken, { + httpOnly: true, + sameSite: "lax", + maxAge: 1 * 60 * 60 * 1000, + }); + + res.status(200).json({ user, accessToken, refreshToken }); + } catch (err) { + next(err); + } + }, + + async logout(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user || !req.user.id) { + return res.status(401).json({ error: "로그인된 사용자만 로그아웃할 수 있습니다." }); + } + + await AuthService.logout(req.user.id); + + // 쿠키에서 토큰 제거 + res.clearCookie("refreshToken"); + res.clearCookie("accessToken"); + + res.status(200).json({ message: "로그아웃 되었습니다." }); + } catch (err) { + next(err); + } + }, + + async refreshToken(req: Request, res: Response, next: NextFunction) { + try { + const result = await AuthService.refreshAccessToken(req.cookies.refreshToken); + if (!result) { + return res.status(401).json({ error: "유효하지 않은 리프레시 토큰입니다." }); + } + const { accessToken, refreshToken: newRefreshToken } = result; + + // 쿠키에 토큰 저장 + res.cookie("refreshToken", newRefreshToken, { + httpOnly: true, + sameSite: "lax", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + res.cookie("accessToken", accessToken, { + httpOnly: true, + sameSite: "lax", + maxAge: 1 * 60 * 60 * 1000, + }); + + res.status(200).json({ accessToken }); + } catch (err) { + next(err); + } + }, +}; + +export default AuthController; diff --git a/src/api/controllers/comment.controller.ts b/src/api/controllers/comment.controller.ts new file mode 100644 index 000000000..517d56e33 --- /dev/null +++ b/src/api/controllers/comment.controller.ts @@ -0,0 +1,72 @@ +import CommentService from "../services/comment/comment.service.js"; +import type { Request, Response, NextFunction } from "express"; +import type { RequestWithDto } from "../types/express.d.ts"; +import { CreateCommentDto, UpdateCommentDto } from "../services/comment/comment.dto.js"; +import { FindManyCommentsQueryDto } from "../services/comment/comment-findmany.dto.js"; +import type { CustomError } from "../types/error.js"; + +const CommentController = { + async createComment(req: RequestWithDto, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const commentDto = req.body; + + const newComment = await CommentService.createComment(commentDto, userId); + res.status(201).json(newComment); + } catch (err) { + next(err); + } + }, + + async updateComment(req: RequestWithDto, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const { id } = req.params; + const commentDto = req.body; + + const comment = await CommentService.updateComment(Number(id), commentDto, userId); + res.status(200).json(comment); + } catch (err) { + next(err); + } + }, + + async deleteComment(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const { id } = req.params; + await CommentService.deleteComment(Number(id), userId); + res.status(200).json({ success: "댓글 삭제" }); + } catch (err) { + next(err); + } + }, + + async findManyComment(req: Request, res: Response, next: NextFunction) { + try { + const params = FindManyCommentsQueryDto.from(req.query); + const comments = await CommentService.findManyComment(params); + + res.status(200).json(comments); + } catch (err) { + next(err); + } + }, +}; + +export default CommentController; diff --git a/src/api/controllers/ImageController.js b/src/api/controllers/image.controller.ts similarity index 71% rename from src/api/controllers/ImageController.js rename to src/api/controllers/image.controller.ts index cf6d6b96e..23d2d6190 100644 --- a/src/api/controllers/ImageController.js +++ b/src/api/controllers/image.controller.ts @@ -1,5 +1,7 @@ +import type { Request, Response } from "express"; + const ImageController = { - uploadImage(req, res) { + uploadImage(req: Request, res: Response) { if (!req.file) { return res.status(400).json({ error: "이미지 파일 없음" }); } diff --git a/src/api/controllers/like.controller.ts b/src/api/controllers/like.controller.ts new file mode 100644 index 000000000..382ad5104 --- /dev/null +++ b/src/api/controllers/like.controller.ts @@ -0,0 +1,30 @@ +import LikeService from "../services/like/like.service.js"; +import type { Request, Response, NextFunction } from "express"; +import type { CustomError } from "../types/error.js"; + +const LikeController = { + async toggleLike(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const { type, id } = req.params; + + if (type !== "product" && type !== "article") { + return res.status(400).json({ error: "type은 product 혹은 article이어야 합니다." }); + } + + const contentId = Number(id); + + const result = await LikeService.toggleLike(userId, type, contentId); + res.status(200).json(result); + } catch (err) { + next(err); + } + }, +}; + +export default LikeController; diff --git a/src/api/controllers/mypage.controller.ts b/src/api/controllers/mypage.controller.ts new file mode 100644 index 000000000..1d4836259 --- /dev/null +++ b/src/api/controllers/mypage.controller.ts @@ -0,0 +1,118 @@ +import MypageService from "../services/mypage/mypage.service.js"; +import type { Request, Response, NextFunction } from "express"; +import type { RequestWithDto } from "../types/express.d.ts"; +import type { UpdateUserDto, UpdatePasswordDto } from "../services/mypage/mypage.dto.js"; +import type { CustomError } from "../types/error.js"; + +const MypageController = { + async getUser(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + + const user = await MypageService.getUser(userId); + res.status(200).json(user); + } catch (err) { + next(err); + } + }, + + async updateUser(req: RequestWithDto, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const updateUserDTO = req.body; + const updatedUser = await MypageService.updateUser(userId, updateUserDTO); + res.status(200).json(updatedUser); + } catch (err) { + next(err); + } + }, + + async updatePassword(req: RequestWithDto, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const updatePasswordDTO = req.body; + + const { accessToken, refreshToken } = await MypageService.updatePassword(userId, updatePasswordDTO); + res.cookie("accessToken", accessToken, { + httpOnly: true, + maxAge: 1 * 60 * 60 * 1000, + }); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + res.status(200).json("비밀번호 변경 및 토큰 재발급이 완료되었습니다."); + } catch (err) { + next(err); + } + }, + + async deleteUser(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + + await MypageService.deleteUser(userId); + res.clearCookie("accessToken"); + res.clearCookie("refreshToken"); + res.status(200).json("회원 탈퇴가 완료되었습니다."); + } catch (err) { + next(err); + } + }, + + async getProducts(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + + const products = await MypageService.getProducts(userId); + res.status(200).json(products); + } catch (err) { + next(err); + } + }, + + async getLikeProducts(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + + const products = await MypageService.getLikeProducts(userId); + res.status(200).json(products); + } catch (err) { + next(err); + } + }, +}; + +export default MypageController; diff --git a/src/api/controllers/product.controller.ts b/src/api/controllers/product.controller.ts new file mode 100644 index 000000000..7821aec35 --- /dev/null +++ b/src/api/controllers/product.controller.ts @@ -0,0 +1,107 @@ +import ProductService from "../services/product/product.service.js"; +import jwt from "jsonwebtoken"; +import { ACCESS_TOKEN_SECRET } from "../libs/constants.js"; +import type { Request, Response, NextFunction } from "express"; +import type { RequestWithDto } from "../types/express.d.ts"; +import { ProductDto } from "../services/product/product.dto.js"; +import { FindManyParamsDto } from "../types/dto.js"; +import type { CustomError } from "src/api/types/error.js"; + +const ProductController = { + async createProduct(req: RequestWithDto, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id: userId } = req.user; + const productDto = req.body; + const newProduct = await ProductService.createProduct(productDto, userId); + + res.status(201).json(newProduct); + } catch (err) { + next(err); + } + }, + + async findUniqueProduct(req: Request, res: Response, next: NextFunction) { + try { + const { id } = req.params; + const productId = Number(id); + + let userId = null; + const token = req.cookies.accessToken; + + if (token) { + try { + const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET); + if (typeof decoded === "string" || !decoded.id) { + const error: CustomError = new Error("유효하지 않은 Token입니다."); + error.statusCode = 403; + throw error; + } + userId = decoded.id; + } catch (err) { + console.error("토큰 검증 오류:", err); + } + } + + const product = await ProductService.findUniqueProduct(productId, userId); + res.status(200).json(product); + } catch (err) { + next(err); + } + }, + + async patchProduct(req: RequestWithDto, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id } = req.params; + const { id: userId } = req.user; + const productDto = req.body; + + const product = await ProductService.patchProduct(Number(id), productDto, userId); + if (!product) { + return res.status(404).json({ error: "수정할 상품이 없음" }); + } + res.status(200).json(product); + } catch (err) { + next(err); + } + }, + + async deleteProduct(req: Request, res: Response, next: NextFunction) { + try { + if (!req.user) { + const error: CustomError = new Error("인증이 필요합니다."); + error.statusCode = 401; + throw error; + } + const { id } = req.params; + const { id: userId } = req.user; + await ProductService.deleteProduct(Number(id), userId); + + res.status(200).json({ success: "상품 삭제 성공" }); + } catch (err) { + next(err); + } + }, + + async findManyProduct(req: Request, res: Response, next: NextFunction) { + try { + const params = FindManyParamsDto.from(req.query); + + const products = await ProductService.findManyProduct(params); + res.status(200).json(products); + } catch (err) { + next(err); + } + }, +}; + +export default ProductController; diff --git a/src/api/libs/constants.js b/src/api/libs/constants.js deleted file mode 100644 index 763e80442..000000000 --- a/src/api/libs/constants.js +++ /dev/null @@ -1,12 +0,0 @@ -import dotenv from "dotenv"; - -dotenv.config(); - -const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; -const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET; - -const CLOUDINARY_CLOUD_NAME = process.env.CLOUDINARY_CLOUD_NAME; -const CLOUDINARY_API_KEY = process.env.CLOUDINARY_API_KEY; -const CLOUDINARY_API_SECRET = process.env.CLOUDINARY_API_SECRET; - -export { ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET, CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET }; diff --git a/src/api/libs/constants.ts b/src/api/libs/constants.ts new file mode 100644 index 000000000..9ccd8529b --- /dev/null +++ b/src/api/libs/constants.ts @@ -0,0 +1,18 @@ +import dotenv from "dotenv"; + +dotenv.config(); + +function getEnvVar(name: string): string { + const value = process.env[name]; + if (value === undefined) { + throw new Error(`환경 변수 ${name}이(가) 설정되지 않았습니다.`); + } + return value; +} + +export const ACCESS_TOKEN_SECRET = getEnvVar("ACCESS_TOKEN_SECRET"); +export const REFRESH_TOKEN_SECRET = getEnvVar("REFRESH_TOKEN_SECRET"); + +export const CLOUDINARY_CLOUD_NAME = getEnvVar("CLOUDINARY_CLOUD_NAME"); +export const CLOUDINARY_API_KEY = getEnvVar("CLOUDINARY_API_KEY"); +export const CLOUDINARY_API_SECRET = getEnvVar("CLOUDINARY_API_SECRET"); diff --git a/src/api/libs/hashing.js b/src/api/libs/hashing.ts similarity index 72% rename from src/api/libs/hashing.js rename to src/api/libs/hashing.ts index 077ada88d..5fe575248 100644 --- a/src/api/libs/hashing.js +++ b/src/api/libs/hashing.ts @@ -1,6 +1,6 @@ import bcrypt from "bcrypt"; -async function hashing(hashword) { +async function hashing(hashword: string) { try { const salt = await bcrypt.genSalt(10); const hashedWord = await bcrypt.hash(hashword, salt); @@ -10,7 +10,7 @@ async function hashing(hashword) { } } -async function compareWords(word, hashedWord) { +async function compareWords(word: string, hashedWord: string) { return await bcrypt.compare(word, hashedWord); } diff --git a/src/api/libs/prismaClient.js b/src/api/libs/prismaClient.ts similarity index 100% rename from src/api/libs/prismaClient.js rename to src/api/libs/prismaClient.ts diff --git a/src/api/libs/token.js b/src/api/libs/token.ts similarity index 68% rename from src/api/libs/token.js rename to src/api/libs/token.ts index 2e50ac6c7..594c7a96c 100644 --- a/src/api/libs/token.js +++ b/src/api/libs/token.ts @@ -1,7 +1,10 @@ import jwt from "jsonwebtoken"; import { ACCESS_TOKEN_SECRET, REFRESH_TOKEN_SECRET } from "./constants.js"; -function generateTokens(userId) { +function generateTokens(userId: number) { + if (!ACCESS_TOKEN_SECRET || !REFRESH_TOKEN_SECRET) { + throw new Error("토큰 시크릿이 설정되지 않았습니다."); + } const accessToken = jwt.sign({ id: userId }, ACCESS_TOKEN_SECRET, { expiresIn: "1h", }); diff --git a/src/api/middlewares/authenticate.js b/src/api/middlewares/authenticate.ts similarity index 51% rename from src/api/middlewares/authenticate.js rename to src/api/middlewares/authenticate.ts index 582756245..66c44be6b 100644 --- a/src/api/middlewares/authenticate.js +++ b/src/api/middlewares/authenticate.ts @@ -1,18 +1,25 @@ import jwt from "jsonwebtoken"; import prisma from "../libs/prismaClient.js"; +import type { Request, Response, NextFunction } from "express"; +import type { CustomError } from "src/api/types/error.js"; import { ACCESS_TOKEN_SECRET } from "../libs/constants.js"; -export default async function authenticate(req, res, next) { +export default async function authenticate(req: Request, res: Response, next: NextFunction) { const { accessToken } = req.cookies; if (!accessToken) { - const error = new Error("인증 토큰이 필요합니다."); + const error: CustomError = new Error("인증 토큰이 필요합니다."); error.statusCode = 401; return next(error); } try { const decoded = jwt.verify(accessToken, ACCESS_TOKEN_SECRET); + if (typeof decoded === "string" || !decoded.id) { + const error: CustomError = new Error("유효하지 않은 Access Token입니다."); + error.statusCode = 403; + throw error; + } const userId = decoded.id; const user = await prisma.user.findUnique({ @@ -20,12 +27,16 @@ export default async function authenticate(req, res, next) { }); if (!user) { - const error = new Error("토큰에 해당하는 사용자를 찾을 수 없습니다."); + const error: CustomError = new Error("토큰에 해당하는 사용자를 찾을 수 없습니다."); error.statusCode = 404; return next(error); } - req.user = user; + req.user = { + id: user.id, + email: user.email, + nickname: user.nickname, + }; next(); } catch (err) { let message; @@ -34,7 +45,7 @@ export default async function authenticate(req, res, next) { } else { message = "유효하지 않은 토큰입니다."; } - const error = new Error(message); + const error: CustomError = new Error(message); error.statusCode = 401; next(error); } diff --git a/src/api/middlewares/errorHandler.js b/src/api/middlewares/errorHandler.js deleted file mode 100644 index a8dee7769..000000000 --- a/src/api/middlewares/errorHandler.js +++ /dev/null @@ -1,11 +0,0 @@ -export default function errorHandler(err, req, res, next) { - console.error(err.stack); - - if (res.headersSent) return next(err); - - if (err.status) { - res.status(err.status).json({ error: err.message }); - } else { - res.status(500).json({ error: "서버 오류" }); - } -} diff --git a/src/api/middlewares/errorHandler.ts b/src/api/middlewares/errorHandler.ts new file mode 100644 index 000000000..5950267e4 --- /dev/null +++ b/src/api/middlewares/errorHandler.ts @@ -0,0 +1,15 @@ +import type { Request, Response, NextFunction } from "express"; +import type { CustomError } from "../types/error.js"; + +export default function errorHandler(err: CustomError, req: Request, res: Response, next: NextFunction) { + console.error(err.stack); + + if (res.headersSent) return next(err); + + const code = err.statusCode ?? err.status; + if (code) { + res.status(code).json({ error: err.message }); + } else { + res.status(500).json({ error: "서버 오류" }); + } +} diff --git a/src/api/middlewares/upload.js b/src/api/middlewares/upload.ts similarity index 68% rename from src/api/middlewares/upload.js rename to src/api/middlewares/upload.ts index 1f0be811f..f6f29f650 100644 --- a/src/api/middlewares/upload.js +++ b/src/api/middlewares/upload.ts @@ -3,6 +3,11 @@ import cloudinary from "cloudinary"; import { CloudinaryStorage } from "multer-storage-cloudinary"; import { CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET } from "../libs/constants.js"; +// cloudinary.v2.config 호출 직전으로 교체 +if (!CLOUDINARY_CLOUD_NAME || !CLOUDINARY_API_KEY || !CLOUDINARY_API_SECRET) { + throw new Error("Cloudinary 환경 변수가 설정되지 않았습니다. 설정해주세요."); +} + cloudinary.v2.config({ cloud_name: CLOUDINARY_CLOUD_NAME, api_key: CLOUDINARY_API_KEY, @@ -11,10 +16,10 @@ cloudinary.v2.config({ const storage = new CloudinaryStorage({ cloudinary: cloudinary.v2, - params: { + params: async () => ({ folder: "sprint-mission-uploads", allowed_formats: ["jpg", "png", "jpeg"], - }, + }), }); const upload = multer({ storage }); diff --git a/src/api/middlewares/validators/validate.js b/src/api/middlewares/validate.ts similarity index 62% rename from src/api/middlewares/validators/validate.js rename to src/api/middlewares/validate.ts index 2bb5dfe4b..4192ef3e4 100644 --- a/src/api/middlewares/validators/validate.js +++ b/src/api/middlewares/validate.ts @@ -1,6 +1,7 @@ -import { ZodError } from "zod"; +import z, { ZodError } from "zod"; +import type { Request, Response, NextFunction } from "express"; -const validate = (schema) => (req, res, next) => { +const validate = (schema: z.ZodSchema) => (req: Request, res: Response, next: NextFunction) => { try { schema.parse(req.body); next(); diff --git a/src/api/middlewares/validator.ts b/src/api/middlewares/validator.ts new file mode 100644 index 000000000..d037b1cdd --- /dev/null +++ b/src/api/middlewares/validator.ts @@ -0,0 +1,43 @@ +import { validate } from "class-validator"; +import type { Request, Response, NextFunction } from "express"; +import type { DtoClass } from "../types/express.d.ts"; + +// req.body 검증 및 DTO 생성 +export const validateDto = (dtoClass: DtoClass) => { + return async (req: Request, res: Response, next: NextFunction) => { + // 1. req.body를 DTO 클래스의 인스턴스로 변환 + const dtoObject = dtoClass.from(req.body); + + // 2. DTO 인스턴스의 유효성 검사 + const errors = await validate(dtoObject); + + if (errors.length > 0) { + const errorMessages = errors.map((error) => { + return Object.values(error.constraints || {}).join(", "); + }); + return res.status(400).json({ errors: errorMessages }); + } + + // 3. 완성된 DTO 객체를 req.body에 할당 + req.body = dtoObject; + next(); + }; +}; + +// req.params 검증 +export const validateParams = (dtoClass: DtoClass) => { + return async (req: Request, res: Response, next: NextFunction) => { + const dtoObject = dtoClass.from(req.params); + + const errors = await validate(dtoObject); + + if (errors.length > 0) { + const errorMessages = errors.map((error) => { + return Object.values(error.constraints || {}).join(", "); + }); + return res.status(400).json({ errors: errorMessages }); + } + + next(); + }; +}; diff --git a/src/api/middlewares/validators/validateArticle.js b/src/api/middlewares/validators/validateArticle.js deleted file mode 100644 index ba15bd759..000000000 --- a/src/api/middlewares/validators/validateArticle.js +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from "zod"; - -export const ArticleSchema = z.object({ - title: z.string().min(1, { message: "제목을 입력하세요" }), - content: z.string().min(1, { message: "내용을 입력하세요." }), -}); diff --git a/src/api/middlewares/validators/validateProduct.js b/src/api/middlewares/validators/validateProduct.js deleted file mode 100644 index fb2b25c7f..000000000 --- a/src/api/middlewares/validators/validateProduct.js +++ /dev/null @@ -1,6 +0,0 @@ -import { z } from "zod"; - -export const ProductSchema = z.object({ - name: z.string().min(1, { message: "상품 이름을 입력하세요" }), - price: z.number().gte(0), -}); diff --git a/src/api/middlewares/validators/validateUser.js b/src/api/middlewares/validators/validateUser.js deleted file mode 100644 index 9681ee35e..000000000 --- a/src/api/middlewares/validators/validateUser.js +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod"; - -// User 유효성 검사 미들웨어는 zod 활용해서 구현해봄 -// 이후에 시간 남으면 다른 유효성 검사도 zod로 바꾸기 -export const signupSchema = z.object({ - email: z.email({ message: "유효한 이메일 형식이 아닙니다." }), - password: z - .string() - .min(8, { message: "비밀번호는 최소 8자 이상이어야합니다." }), - nickname: z - .string() - .min(2, { message: "닉네임은 최소 2자 이상이어야 합니다." }), -}); - -export const loginSchema = z.object({ - email: z.email({ message: "유효한 이메일 형식이 아닙니다." }), - password: z.string().min(1, { message: "비밀번호를 입력해주세요." }), -}); diff --git a/src/api/repositories/article.repository.ts b/src/api/repositories/article.repository.ts new file mode 100644 index 000000000..25489581f --- /dev/null +++ b/src/api/repositories/article.repository.ts @@ -0,0 +1,65 @@ +import prisma from "../libs/prismaClient.js"; +import { Prisma } from "@prisma/client"; +import type { FindManyParams } from "../types/express.d.ts"; + +// 게시글 생성 +export const create = async (data: Prisma.ArticleCreateInput) => { + return await prisma.article.create({ data }); +}; + +// ID로 게시글 조회 +export const findById = async (id: number) => { + return await prisma.article.findUnique({ where: { id } }); +}; + +// 여러 게시글 조회 +export const findMany = async ({ offset = 0, limit = 10, order = "recent", keyword }: FindManyParams) => { + let orderBy: Prisma.ArticleOrderByWithRelationInput; + switch (order) { + case "oldest": + orderBy = { createdAt: "asc" }; + break; + case "recent": + default: + orderBy = { createdAt: "desc" }; + } + + const where: Prisma.ArticleWhereInput = keyword + ? { + OR: [ + { title: { contains: keyword, mode: "insensitive" } }, + { content: { contains: keyword, mode: "insensitive" } }, + ], + } + : {}; + + return await prisma.article.findMany({ + select: { id: true, title: true, content: true, createdAt: true }, + skip: offset, + take: limit, + orderBy, + where, + }); +}; + +// 게시글 수정 +export const update = async (id: number, data: Prisma.ArticleUpdateInput) => { + return await prisma.article.update({ + where: { id }, + data, + }); +}; + +// 게시글 삭제 +export const remove = async (id: number) => { + return await prisma.article.delete({ + where: { id }, + }); +}; + +// 특정 사용자가 특정 게시글에 좋아요 눌렀는지 확인 +export const findLikeByUserAndArticle = async (userId: number, articleId: number) => { + return await prisma.like.findFirst({ + where: { userId, articleId }, + }); +}; diff --git a/src/api/repositories/auth.repository.ts b/src/api/repositories/auth.repository.ts new file mode 100644 index 000000000..65f9590cf --- /dev/null +++ b/src/api/repositories/auth.repository.ts @@ -0,0 +1,32 @@ +import prisma from "../libs/prismaClient.js"; +import type { Prisma } from "@prisma/client"; + +// User 생성 +export const create = async (data: Prisma.UserCreateInput) => { + return await prisma.user.create({ data }); +}; + +// 이메일로 사용자 찾기 +export const findByEmail = async (email: string) => { + return await prisma.user.findUnique({ where: { email } }); +}; + +// id로 사용자 찾기 +export const findById = async (id: number) => { + return await prisma.user.findUnique({ where: { id } }); +}; + +// user 정보 수정 +export const update = async (id: number, data: Prisma.UserUpdateInput) => { + return await prisma.user.update({ + where: { id }, + data, + }); +}; + +export const updateUserRefreshToken = async (id: number, refreshToken: string | null) => { + return await prisma.user.update({ + where: { id }, + data: { refreshToken }, + }); +}; diff --git a/src/api/repositories/comment.repository.ts b/src/api/repositories/comment.repository.ts new file mode 100644 index 000000000..c06f79280 --- /dev/null +++ b/src/api/repositories/comment.repository.ts @@ -0,0 +1,56 @@ +import prisma from "../libs/prismaClient.js"; +import { Prisma } from "@prisma/client"; +import type { FindManyCommentsQuery } from "../services/comment/comment-findmany.dto.js"; + +export const create = async (data: Prisma.CommentCreateInput) => { + return await prisma.comment.create({ data }); +}; + +export const findById = async (id: number) => { + return await prisma.comment.findUnique({ + where: { id }, + }); +}; + +export const update = async (id: number, data: Prisma.CommentUpdateInput) => { + return await prisma.comment.update({ + where: { id }, + data, + }); +}; + +export const remove = async (id: number) => { + return await prisma.comment.delete({ where: { id } }); +}; + +export const findMany = async ({ productId, articleId, cursor, limit = 10 }: FindManyCommentsQuery) => { + let where: Prisma.CommentWhereInput = {}; + + if (productId) { + where.productId = productId; + } else if (articleId) { + where.articleId = articleId; + } + + let skip; + let prismaCursor: Prisma.CommentWhereUniqueInput | undefined; + + if (cursor) { + skip = 1; + prismaCursor = { id: Number(cursor) }; + } + + const comments = await prisma.comment.findMany({ + where, + orderBy: { id: "asc" }, + take: limit, + select: { + id: true, + content: true, + createdAt: true, + }, + ...(skip !== undefined && { skip }), + ...(prismaCursor && { cursor: prismaCursor }), + }); + return comments; +}; diff --git a/src/api/repositories/like.repository.ts b/src/api/repositories/like.repository.ts new file mode 100644 index 000000000..94bfa8a0f --- /dev/null +++ b/src/api/repositories/like.repository.ts @@ -0,0 +1,14 @@ +import prisma from "../libs/prismaClient.js"; +import { Prisma } from "@prisma/client"; + +export const findFirst = async (where: Prisma.LikeWhereInput) => { + return await prisma.like.findFirst({ where }); +}; + +export const create = async (data: Prisma.LikeCreateInput) => { + return await prisma.like.create({ data }); +}; + +export const remove = async (id: number) => { + return await prisma.like.delete({ where: { id } }); +}; diff --git a/src/api/repositories/mypage.repository.ts b/src/api/repositories/mypage.repository.ts new file mode 100644 index 000000000..4f296896d --- /dev/null +++ b/src/api/repositories/mypage.repository.ts @@ -0,0 +1,60 @@ +import prisma from "../libs/prismaClient.js"; +import type { Prisma } from "@prisma/client"; + +export const findUserProfile = async (userId: number) => { + return await prisma.user.findUnique({ + where: { id: userId }, + select: { + email: true, + nickname: true, + image: true, + }, + }); +}; + +export const findUserForAuth = async (userId: number) => { + return await prisma.user.findUnique({ + where: { id: userId }, + }); +}; + +export const update = async (userId: number, data: Prisma.UserUpdateInput) => { + return await prisma.user.update({ + where: { id: userId }, + data, + }); +}; + +export const updatePassword = async (userId: number, hashedNewPassword: string) => { + return await prisma.user.update({ + where: { id: userId }, + data: { password: hashedNewPassword }, + }); +}; + +export const deleteUser = async (userId: number) => { + return await prisma.user.delete({ + where: { id: userId }, + }); +}; + +export const findProductsByUserId = async (userId: number) => { + return await prisma.product.findMany({ + where: { userId }, + orderBy: { + createdAt: "desc", + }, + }); +}; + +export const findLikedProductsByUserId = async (userId: number) => { + return await prisma.like.findMany({ + where: { userId, productId: { not: null } }, + include: { + product: true, + }, + orderBy: { + createdAt: "desc", + }, + }); +}; diff --git a/src/api/repositories/product.repository.ts b/src/api/repositories/product.repository.ts new file mode 100644 index 000000000..1da2c20b2 --- /dev/null +++ b/src/api/repositories/product.repository.ts @@ -0,0 +1,61 @@ +import prisma from "../libs/prismaClient.js"; +import type { FindManyParams } from "../types/express.d.ts"; +import { Prisma } from "@prisma/client"; + +export const create = async (data: Prisma.ProductCreateInput) => { + return await prisma.product.create({ data }); +}; + +export const findById = async (productId: number) => { + return await prisma.product.findUnique({ + where: { id: productId }, + }); +}; + +export const findLikeByUserAndProduct = async (userId: number, productId: number) => { + return await prisma.like.findFirst({ + where: { userId, productId }, + }); +}; + +export const update = async (id: number, data: Prisma.ProductUpdateInput) => { + return await prisma.product.update({ + where: { id }, + data, + }); +}; + +export const remove = async (id: number) => { + return await prisma.product.delete({ + where: { id }, + }); +}; + +export const findMany = async ({ offset = 0, limit = 10, order = "recent", keyword }: FindManyParams) => { + let orderBy: Prisma.ProductOrderByWithRelationInput; + switch (order) { + case "oldest": + orderBy = { createdAt: "asc" }; + break; + case "recent": + default: + orderBy = { createdAt: "desc" }; + } + + let where = {}; + if (keyword) { + where = { + OR: [ + { name: { contains: keyword, mode: "insensitive" } }, + { description: { contains: keyword, mode: "insensitive" } }, + ], + }; + } + return await prisma.product.findMany({ + select: { id: true, name: true, price: true, createdAt: true }, + skip: offset, + take: limit, + orderBy, + where, + }); +}; diff --git a/src/api/routes/ArticleRouter.js b/src/api/routes/ArticleRouter.js deleted file mode 100644 index c93f627d2..000000000 --- a/src/api/routes/ArticleRouter.js +++ /dev/null @@ -1,15 +0,0 @@ -import express from "express"; -import ArticleController from "../controllers/ArticleController.js"; -import authenticate from "../middlewares/authenticate.js"; -import validate from "../middlewares/validators/validate.js"; -import { ArticleSchema } from "../middlewares/validators/validateArticle.js"; - -const router = express.Router(); - -router.post("/", authenticate, validate(ArticleSchema), ArticleController.createArticle); -router.get("/:id", ArticleController.findUniqueArticle); -router.patch("/:id", authenticate, validate(ArticleSchema), ArticleController.updateArticle); -router.delete("/:id", authenticate, ArticleController.deleteArticle); -router.get("/", ArticleController.findManyArticle); - -export default router; diff --git a/src/api/routes/AuthRouter.js b/src/api/routes/AuthRouter.js deleted file mode 100644 index 96a971906..000000000 --- a/src/api/routes/AuthRouter.js +++ /dev/null @@ -1,14 +0,0 @@ -import express from "express"; -import AuthController from "../controllers/AuthController.js"; -import validate from "../middlewares/validators/validate.js"; -import { - signupSchema, - loginSchema, -} from "../middlewares/validators/validateUser.js"; - -const router = express.Router(); - -router.post("/signup", validate(signupSchema), AuthController.signup); -router.post("/login", validate(loginSchema), AuthController.login); -router.post("/refresh-token", AuthController.refreshToken); -export default router; diff --git a/src/api/routes/CommentRouter.js b/src/api/routes/CommentRouter.js deleted file mode 100644 index 44ebb4572..000000000 --- a/src/api/routes/CommentRouter.js +++ /dev/null @@ -1,12 +0,0 @@ -import express from "express"; -import CommentController from "../controllers/CommentController.js"; -import authenticate from "../middlewares/authenticate.js"; - -const router = express.Router(); - -router.post("/", authenticate, CommentController.createComment); -router.patch("/:id", authenticate, CommentController.updateComment); -router.delete("/:id", authenticate, CommentController.deleteComment); -router.get("/", CommentController.findManyComment); - -export default router; diff --git a/src/api/routes/LikeRouter.js b/src/api/routes/LikeRouter.js deleted file mode 100644 index e2f6fb7c7..000000000 --- a/src/api/routes/LikeRouter.js +++ /dev/null @@ -1,9 +0,0 @@ -import express from "express"; -import authenticate from "../middlewares/authenticate.js"; -import LikeController from "../controllers/LikeController.js"; - -const router = express.Router(); - -router.post("/:type/:id", authenticate, LikeController.toggleLike); - -export default router; diff --git a/src/api/routes/MypageRouter.js b/src/api/routes/MypageRouter.js deleted file mode 100644 index fcb71592d..000000000 --- a/src/api/routes/MypageRouter.js +++ /dev/null @@ -1,12 +0,0 @@ -import express from "express"; -import authenticate from "../middlewares/authenticate.js"; -import MypageController from "../controllers/MypageController.js"; -const router = express.Router(); - -router.get("/", authenticate, MypageController.getUser); -router.patch("/", authenticate, MypageController.updateUser); -router.patch("/password", authenticate, MypageController.updatePassword); -router.get("/products", authenticate, MypageController.getProducts); -router.get("/like-products", authenticate, MypageController.getLikeProducts); - -export default router; diff --git a/src/api/routes/ProductRouter.js b/src/api/routes/ProductRouter.js deleted file mode 100644 index 5f794b514..000000000 --- a/src/api/routes/ProductRouter.js +++ /dev/null @@ -1,14 +0,0 @@ -import express from "express"; -import ProductController from "../controllers/ProductController.js"; -import authenticate from "../middlewares/authenticate.js"; -import validate from "../middlewares/validators/validate.js"; -import { ProductSchema } from "../middlewares/validators/validateProduct.js"; -const router = express.Router(); - -router.post("/", authenticate, validate(ProductSchema), ProductController.createProduct); -router.get("/:id", ProductController.findUniqueProduct); -router.patch("/:id", authenticate, validate(ProductSchema), ProductController.patchProduct); -router.delete("/:id", authenticate, ProductController.deleteProduct); -router.get("/", ProductController.findManyProduct); - -export default router; diff --git a/src/api/routes/article.router.ts b/src/api/routes/article.router.ts new file mode 100644 index 000000000..d262bc577 --- /dev/null +++ b/src/api/routes/article.router.ts @@ -0,0 +1,22 @@ +import express from "express"; +import ArticleController from "../controllers/article.controller.js"; +import authenticate from "../middlewares/authenticate.js"; +import { validateDto, validateParams } from "../middlewares/validator.js"; +import { ArticleDto } from "../services/article/article.dto.js"; +import { ArticleIdParamDto } from "../services/article/article-param.dto.js"; + +const router = express.Router(); + +router.post("/", authenticate, validateDto(ArticleDto), ArticleController.createArticle); +router.get("/:id", validateParams(ArticleIdParamDto), ArticleController.findUniqueArticle); +router.patch( + "/:id", + authenticate, + validateParams(ArticleIdParamDto), + validateDto(ArticleDto), + ArticleController.updateArticle +); +router.delete("/:id", authenticate, validateParams(ArticleIdParamDto), ArticleController.deleteArticle); +router.get("/", ArticleController.findManyArticle); + +export default router; diff --git a/src/api/routes/auth.router.ts b/src/api/routes/auth.router.ts new file mode 100644 index 000000000..d643ac491 --- /dev/null +++ b/src/api/routes/auth.router.ts @@ -0,0 +1,12 @@ +import express from "express"; +import AuthController from "../controllers/auth.controller.js"; +import { validateDto } from "../middlewares/validator.js"; +import { SignupDto, LoginDto } from "../services/auth/auth.dto.js"; +import authenticate from "../middlewares/authenticate.js"; +const router = express.Router(); + +router.post("/signup", validateDto(SignupDto), AuthController.signup); +router.post("/login", validateDto(LoginDto), AuthController.login); +router.post("/logout", authenticate, AuthController.logout); +router.post("/refresh-token", AuthController.refreshToken); +export default router; diff --git a/src/api/routes/comment.router.ts b/src/api/routes/comment.router.ts new file mode 100644 index 000000000..0f7a3fa6d --- /dev/null +++ b/src/api/routes/comment.router.ts @@ -0,0 +1,21 @@ +import express from "express"; +import CommentController from "../controllers/comment.controller.js"; +import authenticate from "../middlewares/authenticate.js"; +import { validateDto, validateParams } from "../middlewares/validator.js"; +import { CreateCommentDto, UpdateCommentDto } from "../services/comment/comment.dto.js"; +import { CommentIdParamDto } from "../services/comment/comment-param.dto.js"; + +const router = express.Router(); + +router.post("/", authenticate, validateDto(CreateCommentDto), CommentController.createComment); +router.patch( + "/:id", + authenticate, + validateParams(CommentIdParamDto), + validateDto(UpdateCommentDto), + CommentController.updateComment +); +router.delete("/:id", authenticate, validateParams(CommentIdParamDto), CommentController.deleteComment); +router.get("/", CommentController.findManyComment); + +export default router; diff --git a/src/api/routes/ImageRouter.js b/src/api/routes/image.router.ts similarity index 76% rename from src/api/routes/ImageRouter.js rename to src/api/routes/image.router.ts index 2bcf206fa..27eac09de 100644 --- a/src/api/routes/ImageRouter.js +++ b/src/api/routes/image.router.ts @@ -1,6 +1,6 @@ import express from "express"; import upload from "../middlewares/upload.js"; -import ImageController from "../controllers/ImageController.js"; +import ImageController from "../controllers/image.controller.js"; const router = express.Router(); diff --git a/src/api/routes/like.router.ts b/src/api/routes/like.router.ts new file mode 100644 index 000000000..9f53270bf --- /dev/null +++ b/src/api/routes/like.router.ts @@ -0,0 +1,11 @@ +import express from "express"; +import authenticate from "../middlewares/authenticate.js"; +import LikeController from "../controllers/like.controller.js"; +import { validateParams } from "../middlewares/validator.js"; +import { ToggleLikeParamDto } from "../services/like/like.dto.js"; + +const router = express.Router(); + +router.post("/:type/:id", authenticate, validateParams(ToggleLikeParamDto), LikeController.toggleLike); + +export default router; diff --git a/src/api/routes/mypage.router.ts b/src/api/routes/mypage.router.ts new file mode 100644 index 000000000..556b6e64a --- /dev/null +++ b/src/api/routes/mypage.router.ts @@ -0,0 +1,16 @@ +import express from "express"; +import authenticate from "../middlewares/authenticate.js"; +import MypageController from "../controllers/mypage.controller.js"; +import { validateDto } from "../middlewares/validator.js"; +import { UpdateUserDto, UpdatePasswordDto } from "../services/mypage/mypage.dto.js"; + +const router = express.Router(); + +router.get("/", authenticate, MypageController.getUser); +router.patch("/", authenticate, validateDto(UpdateUserDto), MypageController.updateUser); +router.patch("/password", authenticate, validateDto(UpdatePasswordDto), MypageController.updatePassword); +router.delete("/", authenticate, MypageController.deleteUser); +router.get("/products", authenticate, MypageController.getProducts); +router.get("/like-products", authenticate, MypageController.getLikeProducts); + +export default router; diff --git a/src/api/routes/product.router.ts b/src/api/routes/product.router.ts new file mode 100644 index 000000000..5003e8be2 --- /dev/null +++ b/src/api/routes/product.router.ts @@ -0,0 +1,22 @@ +import express from "express"; +import ProductController from "../controllers/product.controller.js"; +import authenticate from "../middlewares/authenticate.js"; +import { validateDto, validateParams } from "../middlewares/validator.js"; +import { ProductDto } from "../services/product/product.dto.js"; +import { ProductIdParamDto } from "../services/product/product-params.dto.js"; + +const router = express.Router(); + +router.post("/", authenticate, validateDto(ProductDto), ProductController.createProduct); +router.get("/:id", validateParams(ProductIdParamDto), ProductController.findUniqueProduct); +router.patch( + "/:id", + authenticate, + validateParams(ProductIdParamDto), + validateDto(ProductDto), + ProductController.patchProduct +); +router.delete("/:id", authenticate, validateParams(ProductIdParamDto), ProductController.deleteProduct); +router.get("/", ProductController.findManyProduct); + +export default router; diff --git a/src/api/services/ArticleService.js b/src/api/services/ArticleService.js deleted file mode 100644 index 09db54ed3..000000000 --- a/src/api/services/ArticleService.js +++ /dev/null @@ -1,88 +0,0 @@ -import prisma from "../libs/prismaClient.js"; - -const ArticleService = { - async createArticle(articleData, userId) { - const newArticle = await prisma.article.create({ - data: { ...articleData, userId }, - }); - return newArticle; - }, - - async findUniqueArticle(articleId, userId) { - const article = await prisma.article.findUnique({ - where: { id: articleId }, - }); - - if (!userId) { - return { ...article, isLiked: false }; - } - - const like = await prisma.like.findFirst({ - where: { userId, articleId }, - }); - return { ...article, isLiked: !!like }; - }, - - async updateArticle(id, updateData, userId) { - const article = await prisma.article.findUnique({ where: { id } }); - - if (article.userId != userId) { - const error = new Error("게시글을 수정할 권한이 없습니다."); - error.statusCode = 403; - throw error; - } - - return await prisma.article.update({ - where: { id }, - data: updateData, - }); - }, - - async deleteArticle(id, userId) { - const article = await prisma.article.findUnique({ where: { id } }); - - if (article.userId != userId) { - const error = new Error("게시글을 삭제할 권한이 없습니다."); - error.statusCode = 403; - throw error; - } - - await prisma.article.delete({ - where: { id }, - }); - }, - - async findManyArticle({ offset, limit, order, keyword }) { - let orderBy; - switch (order) { - case "oldest": - orderBy = { createdAt: "asc" }; - break; - case "recent": - default: - orderBy = { createdAt: "desc" }; - } - - let where = {}; - - if (keyword) { - where = { - OR: [ - { title: { contains: keyword, mode: "insensitive" } }, - { content: { contains: keyword, mode: "insensitive" } }, - ], - }; - } - - const articles = await prisma.article.findMany({ - select: { id: true, title: true, content: true, createdAt: true }, - skip: parseInt(offset), - take: parseInt(limit), - orderBy, - where, - }); - return articles; - }, -}; - -export default ArticleService; diff --git a/src/api/services/AuthService.js b/src/api/services/AuthService.js deleted file mode 100644 index 787f649c7..000000000 --- a/src/api/services/AuthService.js +++ /dev/null @@ -1,126 +0,0 @@ -import prisma from "../libs/prismaClient.js"; -import jwt from "jsonwebtoken"; -import { hashing, compareWords } from "../libs/hashing.js"; -import { generateTokens } from "../libs/token.js"; - -const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET; - -const AuthService = { - async signup(signupData) { - // 이메일로 이미 존재하는 사용자인지 확인 - const existingUser = await prisma.user.findUnique({ - where: { email: signupData.email }, - }); - - if (existingUser) { - const error = new Error("이미 가입된 이메일입니다."); - error.statusCode = 409; - throw error; - } - - // 사용자 생성 전 비밀번호 해싱 - const { email, nickname, password } = signupData; - const hashedPassword = await hashing(password); - - // 사용자 생성 - const newUser = await prisma.user.create({ - data: { email, nickname, password: hashedPassword }, - }); - - const { password: _, ...userWithoutPassword } = newUser; - return userWithoutPassword; - }, - - async login(loginData) { - // 이메일로 존재하는 사용자인지 확인 - const user = await prisma.user.findUnique({ - where: { email: loginData.email }, - }); - - if (!user) { - const error = new Error("가입되지 않은 사용자입니다"); - error.statusCode = 401; - throw error; - } - - // 비밀번호 일치 여부 확인 - const isPasswordVaild = await compareWords(loginData.password, user.password); - - if (!isPasswordVaild) { - const error = new Error("비밀번호가 일치하지 않습니다."); - error.statusCode = 401; - throw error; - } - - // 액세스 토큰 및 리프레시 토큰 생성 - const { accessToken, refreshToken } = generateTokens(user.id); - - // DB에 리프레시 토큰 저장 - const hashedRefreshToken = await hashing(refreshToken); - await prisma.user.update({ - where: { id: user.id }, - data: { refreshToken: hashedRefreshToken }, - }); - - const { password: _, ...userWithoutPassword } = user; - // 테스트를 위해 refreshToken 출력 - return { userWithoutPassword, accessToken, refreshToken }; - }, - - // AcessToken & RefreshToken을 재발급 받는 메서드 - async refreshAccessToken(oldRefreshToken) { - if (!oldRefreshToken) { - const error = new Error("Refresh Token이 제공되지 않았습니다."); - error.statusCode = 401; - throw error; - } - - try { - // 토큰 디코딩해서 토큰의 User 확인 및 변조 여부 확인 - const decoded = jwt.verify(oldRefreshToken, REFRESH_TOKEN_SECRET); - const userId = decoded.id; - - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - - if (!user) { - const error = new Error("해당하는 user가 없습니다. (Refresh Token 에러)"); - error.statusCode = 403; - throw error; - } - - // DB에 저장된 refreshToken 일치 여부 확인 - const isTokenValid = await compareWords(oldRefreshToken, user.refreshToken); - - if (!isTokenValid) { - const error = new Error("유효하지 않은 Refresh Token입니다."); - error.statusCode = 403; - throw error; - } - - // 새로운 Access Token, Refresh Token 생성 - const { accessToken, refreshToken } = generateTokens(user.id); - const hashedNewRefreshToken = await hashing(refreshToken); - - await prisma.user.update({ - where: { id: user.id }, - data: { refreshToken: hashedNewRefreshToken }, - }); - - return { accessToken, refreshToken }; - } catch (err) { - console.error("Refresh Token 실제 오류:", err); - if (err.name === "TokenExpiredError") { - const error = new Error("Refresh Token이 만료되었습니다. 다시 로그인해주세요."); - error.statusCode = 401; - throw error; - } - const error = new Error("Refresh Token 검증에 실패했습니다."); - error.statusCode = 403; - throw error; - } - }, -}; - -export default AuthService; diff --git a/src/api/services/CommentService.js b/src/api/services/CommentService.js deleted file mode 100644 index 68c7b355d..000000000 --- a/src/api/services/CommentService.js +++ /dev/null @@ -1,75 +0,0 @@ -import prisma from "../libs/prismaClient.js"; - -const CommentService = { - async createComment({ content, productId, articleId, userId }) { - const newComment = await prisma.comment.create({ - data: { - content, - productId: productId || null, - articleId: articleId || null, - userId, - }, - }); - return newComment; - }, - - async updateComment(id, updateData, userId) { - const comment = await prisma.comment.findUnique({ where: { id } }); - - if (comment.userId != userId) { - const error = new Error("댓글을 수정할 권한이 없습니다."); - error.status = 403; - throw error; - } - - return await prisma.comment.update({ - where: { id }, - data: updateData, - }); - }, - - async deleteComment(id, userId) { - const comment = await prisma.comment.findUnique({ where: { id } }); - - if (comment.userId != userId) { - const error = new Error("댓글을 삭제할 권한이 없습니다."); - error.status = 403; - throw error; - } - - return await prisma.comment.delete({ - where: { id }, - }); - }, - - async findManyComment({ productId, articleId, cursor, limit }) { - let where = {}; - if (productId) { - where.productId = productId; - } else { - where.articleId = articleId; - } - - let skip; - if (cursor) { - skip = 1; - cursor = { id: Number(cursor) }; - } - - const comments = await prisma.comment.findMany({ - where, - orderBy: { id: "asc" }, - take: parseInt(limit), - select: { - id: true, - content: true, - createdAt: true, - }, - skip, - cursor, - }); - return comments; - }, -}; - -export default CommentService; diff --git a/src/api/services/LikeService.js b/src/api/services/LikeService.js deleted file mode 100644 index 947e610c6..000000000 --- a/src/api/services/LikeService.js +++ /dev/null @@ -1,30 +0,0 @@ -import prisma from "../libs/prismaClient.js"; - -const LikeService = { - async toggleLike(userId, type, contentId) { - const user = { userId }; - if (type === "article") { - user.articleId = contentId; - } else { - user.productId = contentId; - } - - const existingLike = await prisma.like.findFirst({ - where: user, - }); - - if (existingLike) { - await prisma.like.delete({ - where: { id: existingLike.id }, - }); - return { message: "좋아요가 취소되었습니다.", liked: false }; - } else { - await prisma.like.create({ - data: user, - }); - return { message: "좋아요를 눌렀습니다.", liked: true }; - } - }, -}; - -export default LikeService; diff --git a/src/api/services/MypageService.js b/src/api/services/MypageService.js deleted file mode 100644 index b65bca810..000000000 --- a/src/api/services/MypageService.js +++ /dev/null @@ -1,94 +0,0 @@ -import prisma from "../libs/prismaClient.js"; -import bcrypt from "bcrypt"; - -const MypageService = { - async getUser(userId) { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { - email: true, - nickname: true, - image: true, - }, - }); - - if (!user) { - const error = new Error("사용자를 찾을 수 없습니다."); - error.statusCode = 404; - } - - return user; - }, - - async updateUser(userId, updateData) { - const { email, nickname, image } = updateData; - - const updatedUser = await prisma.user.update({ - where: { id: userId }, - data: { - email, - nickname, - image, - }, - }); - - const { password, refreshToken, ...UserData } = updatedUser; - return UserData; - }, - - async updatePassword(userId, oldPassword, newPassword) { - const user = await prisma.user.findUnique({ - where: { id: userId }, - }); - - if (!user) { - const error = new Error("사용자를 찾을 수 없습니다."); - error.status = 404; - throw error; - } - - // 기존 비밀번호 일치 여부 확인 (입력값으로 기존 비밀번호 받음) - const isPasswordValid = await bcrypt.compare(oldPassword, user.password); - if (!isPasswordValid) { - const error = new Error("기존 비밀번호가 일치하지 않습니다."); - error.statusCode = 403; - throw error; - } - - // 새로운 비밀번호 해시 처리 - const salt = await bcrypt.genSalt(10); - const hashedNewPassword = await bcrypt.hash(newPassword, salt); - - return await prisma.user.update({ - where: { id: userId }, - data: { password: hashedNewPassword }, - }); - }, - - async getProducts(userId) { - const products = await prisma.product.findMany({ - where: { userId }, - orderBy: { - createdAt: "desc", - }, - }); - - return products; - }, - - async getLikeProducts(userId) { - const likedProducts = await prisma.like.findMany({ - where: { userId, productId: { not: null } }, - include: { - product: true, - }, - orderBy: { - createdAt: "desc", - }, - }); - - return likedProducts.map((like) => like.product); - }, -}; - -export default MypageService; diff --git a/src/api/services/ProductService.js b/src/api/services/ProductService.js deleted file mode 100644 index b0220fa62..000000000 --- a/src/api/services/ProductService.js +++ /dev/null @@ -1,86 +0,0 @@ -import prisma from "../libs/prismaClient.js"; - -const ProductService = { - async createProduct(productData, userId) { - const newProduct = await prisma.product.create({ - data: { ...productData, userId }, - }); - return newProduct; - }, - - async findUniqueProduct(productId, userId) { - const product = await prisma.product.findUnique({ - where: { id: productId }, - }); - - if (!userId) { - return { ...product, isLiked: false }; - } - - const like = await prisma.like.findFirst({ - where: { userId, productId }, - }); - return { ...product, isLiked: !!like }; - }, - - async patchProduct(id, updateData, userId) { - const product = await prisma.product.findUnique({ where: { id } }); - - if (product.userId != userId) { - const error = new Error("상품을 수정할 권한이 없습니다."); - error.statusCode = 403; - throw error; - } - return await prisma.product.update({ - where: { id }, - data: updateData, - }); - }, - - async deleteProduct(id, userId) { - const product = await prisma.product.findUnique({ where: { id } }); - - if (product.userId !== userId) { - const error = new Error("상품을 삭제할 권한이 없습니다."); - error.statusCode = 403; - throw error; - } - return await prisma.product.delete({ - where: { id }, - }); - }, - - async findManyProduct({ offset, limit, order, keyword }) { - let orderBy; - switch (order) { - case "oldest": - orderBy = { createdAt: "asc" }; - break; - case "recent": - default: - orderBy = { createdAt: "desc" }; - } - - let where = {}; - if (keyword) { - where = { - OR: [ - { name: { contains: keyword, mode: "insensitive" } }, - { description: { contains: keyword, mode: "insensitive" } }, - ], - }; - } - - const products = await prisma.product.findMany({ - select: { id: true, name: true, price: true, createdAt: true }, - skip: parseInt(offset), - take: parseInt(limit), - orderBy, - where, - }); - - return products; - }, -}; - -export default ProductService; diff --git a/src/api/services/article/article-param.dto.ts b/src/api/services/article/article-param.dto.ts new file mode 100644 index 000000000..f2acefb39 --- /dev/null +++ b/src/api/services/article/article-param.dto.ts @@ -0,0 +1,12 @@ +import { IsNumberString } from "class-validator"; + +export class ArticleIdParamDto { + @IsNumberString({}, { message: "게시글 ID는 숫자 형태여야 합니다." }) + id: string; + + public static from(data: { [key: string]: any }): ArticleIdParamDto { + const dto = new ArticleIdParamDto(); + dto.id = data.id; + return dto; + } +} diff --git a/src/api/services/article/article.dto.ts b/src/api/services/article/article.dto.ts new file mode 100644 index 000000000..6faaac150 --- /dev/null +++ b/src/api/services/article/article.dto.ts @@ -0,0 +1,21 @@ +import { IsString, IsNotEmpty } from "class-validator"; + +export class ArticleDto { + @IsString({ message: "제목은 문자열이어야 합니다." }) + @IsNotEmpty({ message: "제목을 입력하세요." }) + title: string; + + @IsString({ message: "내용은 문자열이어야 합니다." }) + @IsNotEmpty({ message: "내용을 입력하세요." }) + content: string; + + // DTO 생성 메서드 + public static from(data: { [key: string]: any }): ArticleDto { + const dto = new ArticleDto(); + + dto.title = data.title; + dto.content = data.content; + + return dto; + } +} diff --git a/src/api/services/article/article.service.ts b/src/api/services/article/article.service.ts new file mode 100644 index 000000000..cf827aad5 --- /dev/null +++ b/src/api/services/article/article.service.ts @@ -0,0 +1,74 @@ +import type { CustomError } from "../../types/error.js"; +import * as ArticleRepository from "../../repositories/article.repository.js"; +import type { ArticleDto } from "./article.dto.js"; +import type { FindManyParams } from "../../types/express.d.ts"; + +const ArticleService = { + async createArticle(articleData: ArticleDto, userId: number) { + const newArticle = await ArticleRepository.create({ + ...articleData, + user: { connect: { id: userId } }, + }); + return newArticle; + }, + + async findUniqueArticle(articleId: number, userId?: number) { + const article = await ArticleRepository.findById(articleId); + + if (!article) { + throw new Error("존재하지 않는 게시글입니다."); + } + + if (!userId) { + return { ...article, isLiked: false }; + } + + // 좋아요 정보 조회 + const like = await ArticleRepository.findLikeByUserAndArticle(userId, articleId); + + return { ...article, isLiked: !!like }; + }, + + async updateArticle(id: number, updateData: ArticleDto, userId: number) { + const article = await ArticleRepository.findById(id); + + if (!article) { + const error: CustomError = new Error("존재하지 않는 게시글입니다."); + error.statusCode = 404; + throw error; + } + + if (article.userId !== userId) { + const error: CustomError = new Error("게시글을 수정할 권한이 없습니다."); + error.statusCode = 403; + throw error; + } + + return await ArticleRepository.update(id, updateData); + }, + + async deleteArticle(id: number, userId: number) { + const article = await ArticleRepository.findById(id); + + if (!article) { + const error: CustomError = new Error("존재하지 않는 게시글입니다."); + error.statusCode = 404; + throw error; + } + + if (article.userId !== userId) { + const error: CustomError = new Error("게시글을 삭제할 권한이 없습니다."); + error.statusCode = 403; + throw error; + } + + await ArticleRepository.remove(id); + }, + + async findManyArticle(params: FindManyParams) { + const articles = await ArticleRepository.findMany(params); + return articles; + }, +}; + +export default ArticleService; diff --git a/src/api/services/auth/auth.dto.ts b/src/api/services/auth/auth.dto.ts new file mode 100644 index 000000000..f1372b64d --- /dev/null +++ b/src/api/services/auth/auth.dto.ts @@ -0,0 +1,35 @@ +import { IsEmail, IsNotEmpty, MinLength } from "class-validator"; + +export class SignupDto { + @IsEmail({}, { message: "유효한 이메일 형식이 아닙니다." }) + email: string; + + @MinLength(8, { message: "비밀번호는 최소 8자 이상이어야합니다." }) + password: string; + + @MinLength(2, { message: "닉네임은 최소 2자 이상이어야 합니다." }) + nickname: string; + + public static from(data: { [key: string]: any }): SignupDto { + const dto = new SignupDto(); + dto.email = data.email; + dto.password = data.password; + dto.nickname = data.nickname; + return dto; + } +} + +export class LoginDto { + @IsEmail({}, { message: "유효한 이메일 형식이 아닙니다." }) + email: string; + + @IsNotEmpty({ message: "비밀번호를 입력해주세요." }) + password: string; + + public static from(data: { [key: string]: any }): LoginDto { + const dto = new LoginDto(); + dto.email = data.email; + dto.password = data.password; + return dto; + } +} diff --git a/src/api/services/auth/auth.service.ts b/src/api/services/auth/auth.service.ts new file mode 100644 index 000000000..605e36e6d --- /dev/null +++ b/src/api/services/auth/auth.service.ts @@ -0,0 +1,132 @@ +import jwt from "jsonwebtoken"; +import { hashing, compareWords } from "../../libs/hashing.js"; +import { generateTokens } from "../../libs/token.js"; +import { REFRESH_TOKEN_SECRET } from "../../libs/constants.js"; +import type { CustomError } from "src/api/types/error.js"; +import * as AuthRepository from "../../repositories/auth.repository.js"; +import type { SignupDto, LoginDto } from "./auth.dto.js"; + +const AuthService = { + async signup(signupData: SignupDto) { + // 이메일로 이미 존재하는 사용자인지 확인 + const existingUser = await AuthRepository.findByEmail(signupData.email); + + if (existingUser) { + const error: CustomError = new Error("이미 가입된 이메일입니다."); + error.statusCode = 409; + throw error; + } + + // 사용자 생성 전 비밀번호 해싱 + const hashedPassword = await hashing(signupData.password); + + // 사용자 생성 + const newUser = await AuthRepository.create({ + email: signupData.email, + password: hashedPassword, + nickname: signupData.nickname, + }); + + const { password: _, ...userWithoutPassword } = newUser; + return userWithoutPassword; + }, + + async login(loginData: LoginDto) { + // 이메일로 존재하는 사용자인지 확인 + const user = await AuthRepository.findByEmail(loginData.email); + + if (!user) { + const error: CustomError = new Error("가입되지 않은 사용자입니다"); + error.statusCode = 401; + throw error; + } + + // 비밀번호 일치 여부 확인 + const isPasswordVaild = await compareWords(loginData.password, user.password); + + if (!isPasswordVaild) { + const error: CustomError = new Error("비밀번호가 일치하지 않습니다."); + error.statusCode = 401; + throw error; + } + + // 액세스 토큰 및 리프레시 토큰 생성 및 해싱 + const { accessToken, refreshToken } = generateTokens(user.id); + const hashedRefreshToken = await hashing(refreshToken); + + // DB에 리프레시 토큰 저장 + await AuthRepository.updateUserRefreshToken(user.id, hashedRefreshToken); + + const { password: _, ...userWithoutPassword } = user; + // 테스트를 위해 refreshToken 출력 + return { userWithoutPassword, accessToken, refreshToken }; + }, + + // 로그아웃 + async logout(userId: number) { + return await AuthRepository.updateUserRefreshToken(userId, null); + }, + + // AcessToken & RefreshToken을 재발급 받는 메서드 + async refreshAccessToken(oldRefreshToken: string) { + if (!oldRefreshToken) { + const error: CustomError = new Error("Refresh Token이 제공되지 않았습니다."); + error.statusCode = 401; + throw error; + } + + try { + // 토큰 디코딩해서 토큰의 User 확인 및 변조 여부 확인 + const decoded = jwt.verify(oldRefreshToken, REFRESH_TOKEN_SECRET); + if (typeof decoded === "string" || !decoded.id) { + const error: CustomError = new Error("유효하지 않은 Refresh Token입니다."); + error.statusCode = 403; + throw error; + } + + const userId = decoded.id; + + const user = await AuthRepository.findById(userId); + + if (!user) { + const error: CustomError = new Error("해당하는 user가 없습니다. (Refresh Token 에러)"); + error.statusCode = 403; + throw error; + } + + // DB에 저장된 refreshToken 일치 여부 확인 + if (!user.refreshToken) { + console.warn(`사용자 ${user.id}에게 저장된 리프레시 토큰이 없습니다. 제공된 토큰을 무효화합니다.`); + return false; + } + + const isTokenValid = await compareWords(oldRefreshToken, user.refreshToken); + + if (!isTokenValid) { + const error: CustomError = new Error("유효하지 않은 Refresh Token입니다."); + error.statusCode = 403; + throw error; + } + + // 새로운 Access Token, Refresh Token 생성 + const { accessToken, refreshToken } = generateTokens(user.id); + const hashedNewRefreshToken = await hashing(refreshToken); + + await AuthRepository.updateUserRefreshToken(user.id, hashedNewRefreshToken); + + return { accessToken, refreshToken }; + } catch (err) { + console.error("Refresh Token 실제 오류:", err); + if (err.name === "TokenExpiredError") { + const error: CustomError = new Error("Refresh Token이 만료되었습니다. 다시 로그인해주세요."); + error.statusCode = 401; + throw error; + } + const error: CustomError = new Error("Refresh Token 검증에 실패했습니다."); + error.statusCode = 403; + throw error; + } + }, +}; + +export default AuthService; diff --git a/src/api/services/comment/comment-findmany.dto.ts b/src/api/services/comment/comment-findmany.dto.ts new file mode 100644 index 000000000..52dcddded --- /dev/null +++ b/src/api/services/comment/comment-findmany.dto.ts @@ -0,0 +1,22 @@ +import type { ParsedQs } from "qs"; + +export interface FindManyCommentsQuery { + productId?: number; + articleId?: number; + cursor?: string; + limit?: number; +} + +export class FindManyCommentsQueryDto { + public static from(query: ParsedQs): FindManyCommentsQuery { + const { productId, articleId, cursor, limit } = query; + const params: FindManyCommentsQuery = {}; + + if (productId) params.productId = Number(productId); + if (articleId) params.articleId = Number(articleId); + if (typeof cursor === "string") params.cursor = cursor; + if (limit) params.limit = Number(limit); + + return params; + } +} diff --git a/src/api/services/comment/comment-param.dto.ts b/src/api/services/comment/comment-param.dto.ts new file mode 100644 index 000000000..663caaebf --- /dev/null +++ b/src/api/services/comment/comment-param.dto.ts @@ -0,0 +1,12 @@ +import { IsNumberString, MinLength, MaxLength, IsOptional, IsNumber } from "class-validator"; + +export class CommentIdParamDto { + @IsNumberString({}, { message: "댓글 ID는 숫자 형태여야 합니다." }) + id: string; + + public static from(data: { [key: string]: any }): CommentIdParamDto { + const dto = new CommentIdParamDto(); + dto.id = data.id; + return dto; + } +} diff --git a/src/api/services/comment/comment.dto.ts b/src/api/services/comment/comment.dto.ts new file mode 100644 index 000000000..1d09e2d7d --- /dev/null +++ b/src/api/services/comment/comment.dto.ts @@ -0,0 +1,39 @@ +import { IsString, MinLength, MaxLength, IsOptional, IsNumber } from "class-validator"; + +// 댓글 생성용 DTO +export class CreateCommentDto { + @IsString() + @MinLength(1, { message: "댓글 내용은 최소 1자 이상이어야 합니다." }) + @MaxLength(500, { message: "댓글 내용은 최대 500자 이하여야 합니다." }) + content: string; + + @IsNumber() + @IsOptional() + productId?: number; + + @IsNumber() + @IsOptional() + articleId?: number; + + public static from(data: { [key: string]: any }): CreateCommentDto { + const dto = new CreateCommentDto(); + dto.content = data.content; + dto.productId = data.productId; + dto.articleId = data.articleId; + return dto; + } +} + +// 댓글 수정용 DTO +export class UpdateCommentDto { + @IsString() + @MinLength(1, { message: "댓글 내용은 최소 1자 이상이어야 합니다." }) + @MaxLength(500, { message: "댓글 내용은 최대 500자 이하여야 합니다." }) + content: string; + + public static from(data: { [key: string]: any }): UpdateCommentDto { + const dto = new UpdateCommentDto(); + dto.content = data.content; + return dto; + } +} diff --git a/src/api/services/comment/comment.service.ts b/src/api/services/comment/comment.service.ts new file mode 100644 index 000000000..59a1c1a8f --- /dev/null +++ b/src/api/services/comment/comment.service.ts @@ -0,0 +1,83 @@ +import type { CustomError } from "../../types/error.js"; +import * as CommentRepository from "../../repositories/comment.repository.js"; +import type { CreateCommentDto, UpdateCommentDto } from "./comment.dto.js"; +import type { FindManyCommentsQuery } from "./comment-findmany.dto.js"; + +const CommentService = { + async createComment(commentDto: CreateCommentDto, userId: number) { + const { content, productId, articleId } = commentDto; + + if (productId && articleId) { + const error: CustomError = new Error("productId와 articleId 중 하나만 제공되어야 합니다."); + error.statusCode = 400; + throw error; + } + + if (!productId && !articleId) { + const error: CustomError = new Error("productId 또는 articleId는 필수입니다."); + error.statusCode = 400; + throw error; + } + + try { + const newComment = await CommentRepository.create({ + content, + ...(productId && { product: { connect: { id: productId } } }), + ...(articleId && { article: { connect: { id: articleId } } }), + user: { connect: { id: userId } }, + }); + return newComment; + } catch (err) { + if (err.code === "P2003") { + const target = productId ? "상품" : "게시글"; + const error: CustomError = new Error(`존재하지 않는 ${target}입니다.`); + error.statusCode = 404; + throw error; + } + throw err; + } + }, + + async updateComment(id: number, updateData: UpdateCommentDto, userId: number) { + const comment = await CommentRepository.findById(id); + + if (!comment) { + const error: CustomError = new Error("존재하지 않는 댓글입니다."); + error.statusCode = 404; + throw error; + } + + if (comment.userId !== userId) { + const error: CustomError = new Error("댓글을 수정할 권한이 없습니다."); + error.statusCode = 403; + throw error; + } + + return await CommentRepository.update(id, updateData); + }, + + async deleteComment(id: number, userId: number) { + const comment = await CommentRepository.findById(id); + + if (!comment) { + const error: CustomError = new Error("존재하지 않는 댓글입니다."); + error.statusCode = 404; + throw error; + } + + if (comment.userId !== userId) { + const error: CustomError = new Error("댓글을 삭제할 권한이 없습니다."); + error.statusCode = 403; + throw error; + } + + return await CommentRepository.remove(id); + }, + + async findManyComment(params: FindManyCommentsQuery) { + const comments = await CommentRepository.findMany(params); + return comments; + }, +}; + +export default CommentService; diff --git a/src/api/services/like/like.dto.ts b/src/api/services/like/like.dto.ts new file mode 100644 index 000000000..fb002f455 --- /dev/null +++ b/src/api/services/like/like.dto.ts @@ -0,0 +1,16 @@ +import { IsIn, IsNumberString } from "class-validator"; + +export class ToggleLikeParamDto { + @IsIn(["product", "article"], { message: 'type은 "product" 또는 "article"이어야 합니다.' }) + type: "product" | "article"; + + @IsNumberString({}, { message: "ID는 숫자 형태여야 합니다." }) + id: string; + + public static from(data: { [key: string]: any }): ToggleLikeParamDto { + const dto = new ToggleLikeParamDto(); + dto.type = data.type; + dto.id = data.id; + return dto; + } +} diff --git a/src/api/services/like/like.service.ts b/src/api/services/like/like.service.ts new file mode 100644 index 000000000..30a9899ad --- /dev/null +++ b/src/api/services/like/like.service.ts @@ -0,0 +1,43 @@ +import * as LikeRepository from "../../repositories/like.repository.js"; +import * as ArticleRepository from "../../repositories/article.repository.js"; +import * as ProductRepository from "../../repositories/product.repository.js"; +import type { Prisma } from "@prisma/client"; +import type { CustomError } from "../../types/error.js"; + +const LikeService = { + async toggleLike(userId: number, type: "article" | "product", contentId: number) { + if (type === "article") { + const article = await ArticleRepository.findById(contentId); + if (!article) { + const error: CustomError = new Error("존재하지 않는 게시글입니다."); + error.statusCode = 404; + throw error; + } + } else { + const product = await ProductRepository.findById(contentId); + if (!product) { + const error: CustomError = new Error("존재하지 않는 상품입니다."); + error.statusCode = 404; + throw error; + } + } + + const where = type === "article" ? { userId, articleId: contentId } : { userId, productId: contentId }; + const existingLike = await LikeRepository.findFirst(where); + + if (existingLike) { + await LikeRepository.remove(existingLike.id); + return { message: "좋아요가 취소되었습니다.", liked: false }; + } else { + const createData: Prisma.LikeCreateInput = { + user: { connect: { id: userId } }, + ...(type === "article" && { article: { connect: { id: contentId } } }), + ...(type === "product" && { product: { connect: { id: contentId } } }), + }; + await LikeRepository.create(createData); + return { message: "좋아요를 눌렀습니다.", liked: true }; + } + }, +}; + +export default LikeService; diff --git a/src/api/services/mypage/mypage.dto.ts b/src/api/services/mypage/mypage.dto.ts new file mode 100644 index 000000000..9bb28b762 --- /dev/null +++ b/src/api/services/mypage/mypage.dto.ts @@ -0,0 +1,35 @@ +import { IsString, MinLength, IsUrl, IsOptional } from "class-validator"; + +export class UpdateUserDto { + @IsOptional() + @MinLength(2, { message: "닉네임은 최소 2자 이상이어야 합니다." }) + nickname?: string; + + @IsOptional() + @IsUrl({}, { message: "유효한 URL 형식이 아닙니다." }) + image?: string; + + public static from(data: { [key: string]: any }): UpdateUserDto { + const dto = new UpdateUserDto(); + dto.nickname = data.nickname; + dto.image = data.image; + return dto; + } +} + +export class UpdatePasswordDto { + @IsString() + @MinLength(1, { message: "기존 비밀번호를 입력해주세요." }) + oldPassword: string; + + @IsString() + @MinLength(8, { message: "새로운 비밀번호는 최소 8자 이상이어야합니다." }) + newPassword: string; + + public static from(data: { [key: string]: any }): UpdatePasswordDto { + const dto = new UpdatePasswordDto(); + dto.oldPassword = data.oldPassword; + dto.newPassword = data.newPassword; + return dto; + } +} diff --git a/src/api/services/mypage/mypage.service.ts b/src/api/services/mypage/mypage.service.ts new file mode 100644 index 000000000..5aa40efb8 --- /dev/null +++ b/src/api/services/mypage/mypage.service.ts @@ -0,0 +1,101 @@ +import bcrypt from "bcrypt"; +import type { CustomError } from "../../types/error.js"; +import * as MypageRepository from "../../repositories/mypage.repository.js"; +import type { Prisma } from "@prisma/client"; +import { generateTokens } from "../../libs/token.js"; +import { hashing } from "../../libs/hashing.js"; +import * as AuthRepository from "../../repositories/auth.repository.js"; +import { UpdateUserDto, UpdatePasswordDto } from "./mypage.dto.js"; + +const MypageService = { + async getUser(userId: number) { + const user = await MypageRepository.findUserProfile(userId); + + if (!user) { + const error: CustomError = new Error("사용자를 찾을 수 없습니다."); + error.statusCode = 404; + throw error; + } + + return user; + }, + + async updateUser(userId: number, updateData: UpdateUserDto) { + const { nickname, image } = updateData; + + if (!nickname && !image) { + const error: CustomError = new Error("수정할 내용을 하나 이상 입력해주세요."); + error.statusCode = 400; + throw error; + } + + const dataToUpdate: Prisma.UserUpdateInput = {}; + if (nickname) { + dataToUpdate.nickname = nickname; + } + if (image) { + dataToUpdate.image = image; + } + + const updatedUser = await MypageRepository.update(userId, dataToUpdate); + const { password, refreshToken, ...UserData } = updatedUser; + return UserData; + }, + + async updatePassword(userId: number, updatePasswordDTO: UpdatePasswordDto) { + const { oldPassword, newPassword } = updatePasswordDTO; + const user = await MypageRepository.findUserForAuth(userId); + + if (!user) { + const error: CustomError = new Error("사용자를 찾을 수 없습니다."); + error.statusCode = 404; + throw error; + } + + // 기존 비밀번호 일치 여부 확인 (입력값으로 기존 비밀번호 받음) + const isPasswordValid = await bcrypt.compare(oldPassword, user.password); + if (!isPasswordValid) { + const error: CustomError = new Error("기존 비밀번호가 일치하지 않습니다."); + error.statusCode = 403; + throw error; + } + + // 새로운 비밀번호 해시 처리 + const hashedNewPassword = await hashing(newPassword); + await MypageRepository.updatePassword(userId, hashedNewPassword); + + // 새로운 토큰 발급 (Access, Refresh) + const { accessToken, refreshToken } = generateTokens(userId); + + const hashedRefreshToken = await hashing(refreshToken); + await AuthRepository.updateUserRefreshToken(userId, hashedRefreshToken); + + return { accessToken, refreshToken }; + }, + + async deleteUser(userId: number) { + const user = await MypageRepository.findUserProfile(userId); + + if (!user) { + const error: CustomError = new Error("사용자를 찾을 수 없습니다."); + error.statusCode = 404; + throw error; + } + + await MypageRepository.deleteUser(userId); + }, + + async getProducts(userId: number) { + const products = await MypageRepository.findProductsByUserId(userId); + + return products; + }, + + async getLikeProducts(userId: number) { + const likedProducts = await MypageRepository.findLikedProductsByUserId(userId); + + return likedProducts.map((like: { product: any }) => like.product); + }, +}; + +export default MypageService; diff --git a/src/api/services/product/product-params.dto.ts b/src/api/services/product/product-params.dto.ts new file mode 100644 index 000000000..b41e33496 --- /dev/null +++ b/src/api/services/product/product-params.dto.ts @@ -0,0 +1,12 @@ +import { IsNumberString } from "class-validator"; + +export class ProductIdParamDto { + @IsNumberString({}, { message: "상품 ID는 숫자 형태여야 합니다." }) + id: string; + + public static from(data: { [key: string]: any }): ProductIdParamDto { + const dto = new ProductIdParamDto(); + dto.id = data.id; + return dto; + } +} diff --git a/src/api/services/product/product.dto.ts b/src/api/services/product/product.dto.ts new file mode 100644 index 000000000..d252f705c --- /dev/null +++ b/src/api/services/product/product.dto.ts @@ -0,0 +1,26 @@ +import { IsString, IsNotEmpty, IsNumber, IsPositive, IsArray } from "class-validator"; + +export class ProductDto { + @IsString({ message: "제목은 문자열이어야 합니다." }) + @IsNotEmpty({ message: "제목을 입력하세요." }) + name: string; + + @IsNumber({}, { message: "상품 가격은 숫자여야 합니다." }) + @IsPositive({ message: "상품 가격은 0보다 커야 합니다." }) + price: number; + + @IsArray({ message: "태그는 배열이어야 합니다." }) + @IsString({ each: true, message: "각 태그는 문자열이어야 합니다." }) + tags: string[]; + + // DTO 생성 메서드 + public static from(data: { [key: string]: any }): ProductDto { + const dto = new ProductDto(); + + dto.name = data.name; + dto.price = data.price; + dto.tags = data.tags; + + return dto; + } +} diff --git a/src/api/services/product/product.service.ts b/src/api/services/product/product.service.ts new file mode 100644 index 000000000..c94e316b7 --- /dev/null +++ b/src/api/services/product/product.service.ts @@ -0,0 +1,71 @@ +import type { CustomError } from "../../types/error.js"; +import type { FindManyParams } from "../../types/express.d.ts"; +import * as ProductRepository from "../../repositories/product.repository.js"; +import type { ProductDto } from "./product.dto.js"; + +const ProductService = { + async createProduct(productData: ProductDto, userId: number) { + const newProduct = await ProductRepository.create({ + ...productData, + user: { connect: { id: userId } }, + }); + return newProduct; + }, + + async findUniqueProduct(productId: number, userId: number) { + const product = await ProductRepository.findById(productId); + + if (!product) { + const error: CustomError = new Error("존재하지 않는 상품입니다."); + error.statusCode = 404; + throw error; + } + if (!userId) { + return { ...product, isLiked: false }; + } + + const like = await ProductRepository.findLikeByUserAndProduct(userId, productId); + return { ...product, isLiked: !!like }; + }, + + async patchProduct(id: number, updateData: ProductDto, userId: number) { + const product = await ProductRepository.findById(id); + + if (!product) { + const error: CustomError = new Error("존재하지 않는 상품입니다."); + error.statusCode = 404; + throw error; + } + + if (product.userId !== userId) { + const error: CustomError = new Error("상품을 수정할 권한이 없습니다."); + error.statusCode = 403; + throw error; + } + return await ProductRepository.update(id, updateData); + }, + + async deleteProduct(id: number, userId: number) { + const product = await ProductRepository.findById(id); + + if (!product) { + const error: CustomError = new Error("존재하지 않는 상품입니다."); + error.statusCode = 404; + throw error; + } + + if (product.userId !== userId) { + const error: CustomError = new Error("상품을 삭제할 권한이 없습니다."); + error.statusCode = 403; + throw error; + } + return await ProductRepository.remove(id); + }, + + async findManyProduct(params: FindManyParams) { + const products = await ProductRepository.findMany(params); + return products; + }, +}; + +export default ProductService; diff --git a/src/api/types/dto.ts b/src/api/types/dto.ts new file mode 100644 index 000000000..7e683f8ab --- /dev/null +++ b/src/api/types/dto.ts @@ -0,0 +1,25 @@ +import type { ParsedQs } from "qs"; +import type { FindManyParams } from "../types/express.d.ts"; + +export class FindManyParamsDto { + public static from(query: ParsedQs): FindManyParams { + const { offset, limit, order, keyword } = query; + + const params: FindManyParams = {}; + + // 값이 존재할 경우에만 객체에 속성을 추가 + if (offset) { + params.offset = Number(offset); + } + if (limit) { + params.limit = Number(limit); + } + if (typeof order === "string") { + params.order = order; + } + if (typeof keyword === "string") { + params.keyword = keyword; + } + return params; + } +} diff --git a/src/api/types/error.ts b/src/api/types/error.ts new file mode 100644 index 000000000..bdaa3ca61 --- /dev/null +++ b/src/api/types/error.ts @@ -0,0 +1,4 @@ +export interface CustomError extends Error { + statusCode?: number; + status?: number; +} diff --git a/src/api/types/express.d.ts b/src/api/types/express.d.ts new file mode 100644 index 000000000..7f40889dd --- /dev/null +++ b/src/api/types/express.d.ts @@ -0,0 +1,27 @@ +import { Request } from "express"; +import { AuthenticatedUser } from "./user.d.js"; + +declare global { + namespace Express { + interface Request { + user?: AuthenticatedUser; + file?: Multer.File; + } + } +} + +export interface RequestWithDto extends Request { + body: T; +} + +export type DtoClass = { + from(data: any): T; +}; + +// 목록 조회 파라미터 타입 정의 +export interface FindManyParams { + offset?: number; + limit?: number; + order?: string; + keyword?: string; +} diff --git a/src/api/types/user.d.ts b/src/api/types/user.d.ts new file mode 100644 index 000000000..0078c8a8a --- /dev/null +++ b/src/api/types/user.d.ts @@ -0,0 +1,5 @@ +export interface AuthenticatedUser { + id: number; + email: string; + nickname: string; +} diff --git a/src/external/classes/ElectronicProduct.js b/src/external/classes/ElectronicProduct.js deleted file mode 100644 index 5a1fbf630..000000000 --- a/src/external/classes/ElectronicProduct.js +++ /dev/null @@ -1,8 +0,0 @@ -import { Product } from "./Product.js"; - -export class ElectronicProduct extends Product { - constructor({ manufacturer, ...rest }) { - super(rest); - this.manufacturer = manufacturer; - } -} diff --git a/src/external/classes/Product.js b/src/external/classes/Product.js deleted file mode 100644 index 2e1c31a1a..000000000 --- a/src/external/classes/Product.js +++ /dev/null @@ -1,14 +0,0 @@ -export class Product { - constructor({ name, description, price, tags, images, favoriteCount }) { - this.name = name; - this.description = description; - this.price = price; - this.tags = tags; - this.images = images; - this.favoriteCount = favoriteCount; - } - - favorite() { - this.favoriteCount += 1; - } -} diff --git a/src/external/classes/Article.js b/src/external/classes/article.ts similarity index 53% rename from src/external/classes/Article.js rename to src/external/classes/article.ts index 359cb794f..59489ff3b 100644 --- a/src/external/classes/Article.js +++ b/src/external/classes/article.ts @@ -1,5 +1,11 @@ class Article { - constructor(title, content, writer, likeCount) { + title: string; + content: string; + writer: string; + likeCount: number; + createdAt: number; + + constructor(title: string, content: string, writer: string, likeCount: number) { this.title = title; this.content = content; this.writer = writer; diff --git a/src/external/classes/electronic_product.ts b/src/external/classes/electronic_product.ts new file mode 100644 index 000000000..6fd7b93ab --- /dev/null +++ b/src/external/classes/electronic_product.ts @@ -0,0 +1,26 @@ +import { Product } from "./product.js"; + +export class ElectronicProduct extends Product { + manufacturer: string; + + constructor({ + manufacturer, + name, + description, + price, + tags, + images, + favoriteCount, + }: { + manufacturer: string; + name: string; + description: string; + price: number; + tags: string[]; + images: string[]; + favoriteCount: number; + }) { + super(name, description, price, tags, images, favoriteCount); + this.manufacturer = manufacturer; + } +} diff --git a/src/external/classes/product.ts b/src/external/classes/product.ts new file mode 100644 index 000000000..0b3284d72 --- /dev/null +++ b/src/external/classes/product.ts @@ -0,0 +1,28 @@ +export class Product { + name: string; + description: string; + price: number; + tags: string[]; + images: string[]; + favoriteCount: number; + + constructor( + name: string, + description: string, + price: number, + tags: string[], + images: string[], + favoriteCount: number + ) { + this.name = name; + this.description = description; + this.price = price; + this.tags = tags; + this.images = images; + this.favoriteCount = favoriteCount; + } + + favorite() { + this.favoriteCount += 1; + } +} diff --git a/src/external/services/article/article.dto.ts b/src/external/services/article/article.dto.ts new file mode 100644 index 000000000..a6d54be7e --- /dev/null +++ b/src/external/services/article/article.dto.ts @@ -0,0 +1,43 @@ +export interface ArticleListQuery { + page?: number; + limit?: number; + sort?: string; + order?: "asc" | "desc"; + search?: string; + [key: string]: any; +} + +export interface ArticleData { + id: string; + title: string; + content: string; + likeCount: number; + createdAt: number; +} + +export interface CreateArticleDto { + title: string; + content: string; +} + +export interface PatchArticleDto { + title: string; + content: string; +} + +export interface ArticleListResponse { + list: ArticleData[]; +} + +export function isArticleData(item: unknown): item is ArticleData { + if (typeof item !== "object" || item === null) return false; + const article = item as Record; + return ( + typeof article.id === "string" && + typeof article.title === "string" && + typeof article.content === "string" && + typeof article.writer === "string" && + typeof article.likeCount === "number" && + typeof article.createdAt === "number" + ); +} diff --git a/src/external/services/ArticleService.js b/src/external/services/article/article.service.ts similarity index 76% rename from src/external/services/ArticleService.js rename to src/external/services/article/article.service.ts index d79af3608..511d7d3ec 100644 --- a/src/external/services/ArticleService.js +++ b/src/external/services/article/article.service.ts @@ -1,20 +1,24 @@ +import type { ArticleListQuery, ArticleListResponse, CreateArticleDto, PatchArticleDto } from "./article.dto.js"; + const BASE_URL = "https://panda-market-api-crud.vercel.app/articles"; const ArticleService = { // 기사 목록 조회 - getArticleList(query) { + getArticleList(query?: ArticleListQuery): Promise { const url = new URL(BASE_URL); - for (const [key, value] of Object.entries(query)) { - if (value !== undefined) { - url.searchParams.append(key, value); + if (query) { + for (const key in query) { + if (query[key] !== undefined) { + url.searchParams.append(key, String(query[key])); + } } } - return fetch(url) + return fetch(url.toString()) .then((res) => { if (!res.ok) throw new Error(`[error] 상태 코드: ${res.status}`); - return res.json(); + return res.json() as Promise; // 이후에 수정.. }) .catch((err) => { console.error("[error] 요청 실패:", err.message); @@ -23,7 +27,7 @@ const ArticleService = { }, // 특정 기사 조회 - getArticle(id) { + getArticle(id: string) { return fetch(`${BASE_URL}/${id}`) .then((res) => { if (!res.ok) throw new Error(`[error] 상태 코드: ${res.status}`); @@ -36,7 +40,7 @@ const ArticleService = { }, // 기사 생성 - createArticle(articleData) { + createArticle(articleData: CreateArticleDto) { return fetch(BASE_URL, { method: "POST", headers: { @@ -55,7 +59,7 @@ const ArticleService = { }, // 기사 수정 - patchArticle(id, articlePatchData) { + patchArticle(id: string, articlePatchData: PatchArticleDto) { return fetch(`${BASE_URL}/${id}`, { method: "PATCH", headers: { @@ -74,7 +78,7 @@ const ArticleService = { }, // 기사 삭제 - deleteArticle(id) { + deleteArticle(id: string) { return fetch(`${BASE_URL}/${id}`, { method: "DELETE", }) diff --git a/src/external/services/product/product.dto.ts b/src/external/services/product/product.dto.ts new file mode 100644 index 000000000..902407471 --- /dev/null +++ b/src/external/services/product/product.dto.ts @@ -0,0 +1,52 @@ +// getProductList의 쿼리 파라미터 타입 +export interface ProductListQuery { + page?: number; + limit?: number; + sort?: string; + order?: "asc" | "desc"; + search?: string; + [key: string]: any; +} + +// API로부터 받는 상품 데이터의 타입 +export interface ProductData { + id: string; + name: string; + description: string; + price: number; + tags: string[]; + images: string[]; + favoriteCount: number; +} + +// API로부터 받는 전자상품 데이터의 타입 +export interface ElectronicProductData { + id: string; + manufacturer: string; + name: string; + description: string; + price: number; + tags: string[]; + images: string[]; + favoriteCount: number; +} + +// 상품 생성을 위한 데이터 타입 +export interface CreateProductDto { + name: string; + price: number; + tags?: string[]; + image?: string[]; + description: string; +} + +// 상품 수정을 위한 데이터 타입 +export interface PatchProductDto { + name?: string; + price?: number; + tags?: string[]; +} + +export interface ProductListResponse { + list: ProductData[]; +} diff --git a/src/external/services/ProductService.js b/src/external/services/product/product.service.ts similarity index 76% rename from src/external/services/ProductService.js rename to src/external/services/product/product.service.ts index de597d9a5..c22da324c 100644 --- a/src/external/services/ProductService.js +++ b/src/external/services/product/product.service.ts @@ -1,10 +1,11 @@ -import axios from "axios"; +import axios, { type AxiosResponse } from "axios"; +import type { CreateProductDto, PatchProductDto, ProductListQuery, ProductListResponse } from "./product.dto.js"; const BASE_URL = "https://panda-market-api-crud.vercel.app/products"; const ProductService = { // 상품 리스트 조회 - async getProductList(query) { + async getProductList(query?: ProductListQuery): Promise { const url = new URL(BASE_URL); for (let key in query) { @@ -14,7 +15,7 @@ const ProductService = { } try { - const res = await axios.get(url); + const res = await axios.get(url.toString()); const data = await res.data; return data; } catch (err) { @@ -24,7 +25,7 @@ const ProductService = { }, // 개별 상품 조회 - async getProduct(id) { + async getProduct(id: string) { try { const res = await axios.get(`${BASE_URL}/${id}`); const data = await res.data; @@ -36,7 +37,7 @@ const ProductService = { }, // 상품 등록 - async createProduct(productData) { + async createProduct(productData: CreateProductDto) { try { const res = await axios.post(BASE_URL, productData); const data = await res.data; @@ -48,7 +49,7 @@ const ProductService = { }, // 상품 정보 수정 - async patchProduct(id, productPatchData) { + async patchProduct(id: string, productPatchData: PatchProductDto) { try { const res = await axios.patch(`${BASE_URL}/${id}`, productPatchData); const data = res.data; @@ -60,7 +61,7 @@ const ProductService = { }, // 상품 삭제 - async deleteProduct(id) { + async deleteProduct(id: string) { try { const res = await axios.delete(`${BASE_URL}/${id}`); const data = res.data; diff --git a/src/external/stores/ProductStore.js b/src/external/stores/ProductStore.js deleted file mode 100644 index 122a63054..000000000 --- a/src/external/stores/ProductStore.js +++ /dev/null @@ -1,41 +0,0 @@ -import ProductService from "../services/ProductService.js"; -import { Product } from "../classes/Product.js"; -import { ElectronicProduct } from "../classes/ElectronicProduct.js"; - -const products = []; - -// const result = await ProductService.getProductList(); -// const data = result.list; -// console.log(data); - -async function loadProducts(query) { - try { - const result = await ProductService.getProductList(query); - const data = result.list; - - const electronicProducts = data - .filter((ele) => ele.tags.includes("전자제품")) - .map((ele) => new ElectronicProduct(ele)); - - const normalProducts = data - .filter((ele) => !ele.tags.includes("전자제품")) - .map((ele) => new Product(ele)); - - products.splice(0, products.length, ...electronicProducts, normalProducts); - - // for (let ele of data) { - // const isElectronic = ele.tags.includes("전자제품"); - // let product; - // if (isElectronic) { - // product = new ElectronicProduct(ele); - // } else { - // product = new Product(ele); - // } - - // products.push(product); - // } - } catch (err) { - console.log("[store error] 상품을 불러오지 못했습니다."); - throw err; - } -} diff --git a/src/external/stores/product_store.ts b/src/external/stores/product_store.ts new file mode 100644 index 000000000..719adc1f2 --- /dev/null +++ b/src/external/stores/product_store.ts @@ -0,0 +1,47 @@ +import ProductService from "../services/product/product.service.js"; +import { Product } from "../classes/product.js"; +import { ElectronicProduct } from "../classes/electronic_product.js"; +import type { ProductData, ProductListQuery, ElectronicProductData } from "../services/product/product.dto.js"; + +const products: (Product | ElectronicProduct)[] = []; + +async function loadProducts(query?: ProductListQuery): Promise { + try { + const result = await ProductService.getProductList(query); + const data = result.list; + + const electronicProducts = data + .filter((ele: ElectronicProductData) => ele.tags.includes("전자제품")) + .map( + (ele: ElectronicProductData) => + new ElectronicProduct({ + manufacturer: ele.manufacturer || "Unknown", + name: ele.name, + description: ele.description || "No description", + price: ele.price, + tags: ele.tags, + images: ele.images || [], + favoriteCount: ele.favoriteCount || 0, + }) + ); + + const normalProducts = data + .filter((ele: ProductData) => !ele.tags.includes("전자제품")) + .map( + (ele: ProductData) => + new Product( + ele.name, + ele.description || "No description", + ele.price, + ele.tags, + ele.images || [], + ele.favoriteCount || 0 + ) + ); + + products.splice(0, products.length, ...electronicProducts, ...normalProducts); + } catch (err) { + console.log("[store error] 상품을 불러오지 못했습니다."); + throw err; + } +} diff --git a/src/external/tests/testArticleService.js b/src/external/tests/test_article.service.ts similarity index 65% rename from src/external/tests/testArticleService.js rename to src/external/tests/test_article.service.ts index 993237f78..f32a28759 100644 --- a/src/external/tests/testArticleService.js +++ b/src/external/tests/test_article.service.ts @@ -1,4 +1,6 @@ -import ArticleService from "../services/ArticleService.js"; +import { create } from "node_modules/axios/index.cjs"; +import ArticleService from "../services/article/article.service.js"; +import { isArticleData } from "../services/article/article.dto.js"; // const res = await ArticleService.getArticleList(); // console.log(res.list.length); @@ -26,7 +28,10 @@ export async function testAllArticleService() { image: "https://example.com/...", }; const created = await ArticleService.createArticle(newArticle); - createdId = created.id; + + if (isArticleData(created)) { + createdId = created.id; + } console.log(`생성한 기사 id : ${createdId}`); } catch (err) { console.log("❌", err); @@ -34,25 +39,31 @@ export async function testAllArticleService() { console.log("--------특정 기사 조회---------"); try { - const article = await ArticleService.getArticle(createdId); - console.log(`[생성한 기사]`, article); + if (createdId) { + const article = await ArticleService.getArticle(createdId); + console.log(`[생성한 기사]`, article); + } } catch (err) { console.log("❌", err); } console.log("--------기사 수정---------"); try { - const patchData = { title: "제목 수정했음" }; - const updated = await ArticleService.patchArticle(createdId, patchData); - console.log(`[기사 정보 수정 성공]`, updated); + const patchData = { title: "제목 수정했음", content: "수정된 내용" }; + if (createdId) { + const updated = await ArticleService.patchArticle(createdId, patchData); + console.log(`[기사 정보 수정 성공]`, updated); + } } catch (err) { console.log("❌", err); } console.log("--------기사 삭제---------"); try { - const deleted = await ArticleService.deleteArticle(createdId); - console.log(`[기사 삭제 성공]`, deleted); + if (createdId) { + const deleted = await ArticleService.deleteArticle(createdId); + console.log(`[기사 삭제 성공]`, deleted); + } } catch (err) { console.log("❌", err); } diff --git a/src/external/tests/testProductService.js b/src/external/tests/test_product.service.ts similarity index 91% rename from src/external/tests/testProductService.js rename to src/external/tests/test_product.service.ts index ff2303eec..a2359ac9f 100644 --- a/src/external/tests/testProductService.js +++ b/src/external/tests/test_product.service.ts @@ -1,4 +1,4 @@ -import ProductService from "../services/ProductService.js"; +import ProductService from "../services/product/product.service.js"; // const res = await ProductService.getProductList(); // console.log(res); @@ -42,10 +42,7 @@ export async function testAllProductService() { console.log("-------개별 상품 수정--------"); try { const patchProductData = { name: "이름을 수정함" }; - const updated = await ProductService.patchProduct( - createdId, - patchProductData - ); + const updated = await ProductService.patchProduct(createdId, patchProductData); console.log(`[상품 정보 수정 성공]`, updated); } catch (err) { console.log(`❌`, err); diff --git a/src/main.js b/src/main.ts similarity index 65% rename from src/main.js rename to src/main.ts index d62461a3b..00fd955e7 100644 --- a/src/main.js +++ b/src/main.ts @@ -1,18 +1,18 @@ import express from "express"; -import ProductRouter from "./api/routes/ProductRouter.js"; -import ArticleRouter from "./api/routes/ArticleRouter.js"; -import CommentRouter from "./api/routes/CommentRouter.js"; +import ProductRouter from "./api/routes/product.router.js"; +import ArticleRouter from "./api/routes/article.router.js"; +import CommentRouter from "./api/routes/comment.router.js"; import errorHandler from "./api/middlewares/errorHandler.js"; -import imageRouter from "./api/routes/ImageRouter.js"; -import AuthRouter from "./api/routes/AuthRouter.js"; -import MypageRouter from "./api/routes/MypageRouter.js"; -import LikeRouter from "./api/routes/LikeRouter.js"; +import imageRouter from "./api/routes/image.router.js"; +import AuthRouter from "./api/routes/auth.router.js"; +import MypageRouter from "./api/routes/mypage.router.js"; +import LikeRouter from "./api/routes/like.router.js"; import cookieParser from "cookie-parser"; // import { testAllArticleService } from "./external/tests/testArticleService.js"; // import { testAllProductService } from "./external/tests/testProductService.js"; -// //testAllArticleService(); +// testAllArticleService(); // testAllProductService(); const app = express(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..7aed0da82 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,47 @@ +{ + "compilerOptions": { + // Basics + "module": "nodenext", + "target": "es2024", + "lib": ["esnext"], + "types": ["node"], // install -D @types/node + + // File Layout + "baseUrl": "./", + "rootDir": "./src", + "outDir": "./dist", + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "removeComments": true, + + // Decorators + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Style Options + "noImplicitAny": true, + "noImplicitOverride": true, + "noUncheckedSideEffectImports": true, + "forceConsistentCasingInFileNames": false, + "strictNullChecks": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + + // Recommended Options + "incremental": true, + "skipLibCheck": true, + //"strict": true, + "isolatedModules": true, + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "moduleResolution": "nodenext" + }, + "include": ["src/**/*", "test"] +}