diff --git a/kill-ports.sh b/kill-ports.sh new file mode 100644 index 0000000..a1860e1 --- /dev/null +++ b/kill-ports.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Script para matar procesos en puertos de desarrollo +PORTS=(3001 3002 3003 3004 3005) + +echo "🔍 Buscando procesos en puertos de desarrollo..." + +for PORT in "${PORTS[@]}"; do + PID=$(netstat -ano | grep ":$PORT " | grep LISTENING | awk '{print $5}' | head -1) + if [ ! -z "$PID" ]; then + echo "🔥 Terminando proceso PID $PID en puerto $PORT" + taskkill.exe //PID $PID //F 2>/dev/null + else + echo "✅ Puerto $PORT ya está libre" + fi +done + +echo "🎉 Limpieza completada!" +echo "Puertos disponibles:" +netstat -ano | grep -E ":(3001|3002|3003|3004|3005)" | grep LISTENING || echo "Todos los puertos están libres ✅" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 13a48e6..0d6d48c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,21 +15,27 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.3.0", "@prisma/client": "^6.13.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cloudinary": "^1.41.3", + "multer": "^2.0.2", + "multer-storage-cloudinary": "^4.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", - "@types/express": "^5.0.0", + "@types/express": "^5.0.3", "@types/jest": "^29.5.2", + "@types/multer": "^2.0.0", "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", @@ -39,6 +45,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", + "kill-port": "^2.0.1", "prettier": "^3.0.0", "prisma": "^6.13.0", "source-map-support": "^0.5.21", @@ -46,6 +53,7 @@ "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" } @@ -1588,6 +1596,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", + "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "10.4.9", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", @@ -1827,6 +1841,26 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", + "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", @@ -1882,6 +1916,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/swagger": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.5", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.17.14" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.20.tgz", @@ -2098,6 +2165,13 @@ "@prisma/debug": "6.13.0" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2396,6 +2470,16 @@ "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.19.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", @@ -2488,6 +2572,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/superagent": { "version": "8.1.9", "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", @@ -3171,7 +3269,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -3355,6 +3452,16 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", + "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -3457,9 +3564,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", "dev": true, "funding": [ { @@ -3477,9 +3584,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { @@ -3697,9 +3805,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001731", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", - "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "version": "1.0.30001750", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", + "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", "dev": true, "funding": [ { @@ -3940,6 +4048,30 @@ "node": ">=0.8" } }, + "node_modules/cloudinary": { + "version": "1.41.3", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-1.41.3.tgz", + "integrity": "sha512-4o84y+E7dbif3lMns+p3UW6w6hLHEifbX/7zBJvaih1E9QNMZITENQ14GPYJC4JmhygYXsuuBb9bRA3xWEoOfg==", + "license": "MIT", + "dependencies": { + "cloudinary-core": "^2.13.0", + "core-js": "^3.30.1", + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/cloudinary-core": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/cloudinary-core/-/cloudinary-core-2.14.0.tgz", + "integrity": "sha512-L+kjoYgU+5wyiPkSnmeCbmtT6DwSyYUN/WoI/fEb6Xsx2gtB3iuf/50W0SvcQkeKzllfH5Knh8I4ST924DkkRw==", + "license": "MIT", + "peerDependencies": { + "lodash": ">=4.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4111,6 +4243,17 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz", + "integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -4430,6 +4573,16 @@ "node": ">= 0.4" } }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4464,9 +4617,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.198", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.198.tgz", - "integrity": "sha512-G5COfnp3w+ydVu80yprgWSfmfQaYRh9DOxfhAxstLyetKaLyl55QrNjx8C38Pc/C+RaDmb1M0Lk8wPEMQ+bGgQ==", + "version": "1.5.234", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.234.tgz", + "integrity": "sha512-RXfEp2x+VRYn8jbKfQlRImzoJU01kyDvVPBmG39eU2iuRVhuS6vQNocB8J0/8GrIMLnPzgz4eW6WiRnJkTuNWg==", "dev": true, "license": "ISC" }, @@ -5561,6 +5714,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-them-args": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/get-them-args/-/get-them-args-1.3.2.tgz", + "integrity": "sha512-LRn8Jlk+DwZE4GTlDbT3Hikd1wSHgLMme/+7ddlqKd7ldwR6LjJgTVWzBnR01wnYGe4KgrXjg287RaI22UHmAw==", + "dev": true, + "license": "MIT" + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -6963,7 +7123,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7099,6 +7258,20 @@ "json-buffer": "3.0.1" } }, + "node_modules/kill-port": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kill-port/-/kill-port-2.0.1.tgz", + "integrity": "sha512-e0SVOV5jFo0mx8r7bS29maVWp17qGqLBZ5ricNSajON6//kmb7qqqNnml4twNE8Dtj97UQD+gNFOaipS/q1zzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-them-args": "1.3.2", + "shell-exec": "1.0.2" + }, + "bin": { + "kill-port": "cli.js" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -7514,6 +7687,15 @@ "node": ">= 10.16.0" } }, + "node_modules/multer-storage-cloudinary": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/multer-storage-cloudinary/-/multer-storage-cloudinary-4.0.0.tgz", + "integrity": "sha512-25lm9R6o5dWrHLqLvygNX+kBOxprzpmZdnVKH4+r68WcfCt8XV6xfQaMuAg+kUE5Xmr8mJNA4gE0AcBj9FJyWA==", + "license": "MIT", + "peerDependencies": { + "cloudinary": "^1.21.0" + } + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -7616,9 +7798,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.23", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", + "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", "dev": true, "license": "MIT" }, @@ -8298,6 +8480,17 @@ ], "license": "MIT" }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -8957,6 +9150,13 @@ "node": ">=8" } }, + "node_modules/shell-exec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/shell-exec/-/shell-exec-1.0.2.tgz", + "integrity": "sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==", + "dev": true, + "license": "MIT" + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -9376,6 +9576,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==", + "license": "Apache-2.0" + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/swagger-ui-express/node_modules/swagger-ui-dist": { + "version": "5.29.4", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.29.4.tgz", + "integrity": "sha512-gJFDz/gyLOCQtWwAgqs6Rk78z9ONnqTnlW11gimG9nLap8drKa3AJBKpzIQMIjl5PD2Ix+Tn+mc/tfoT2tgsng==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", @@ -9403,13 +9633,17 @@ } }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/terser": { @@ -9825,6 +10059,127 @@ } } }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/ts-node-dev/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/ts-node-dev/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-node-dev/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ts-node-dev/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-node-dev/node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -9866,6 +10221,26 @@ "node": ">=4" } }, + "node_modules/tsconfig/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tsconfig/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -10165,9 +10540,9 @@ "license": "BSD-2-Clause" }, "node_modules/webpack": { - "version": "5.101.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.0.tgz", - "integrity": "sha512-B4t+nJqytPeuZlHuIKTbalhljIFXeNRqrUGAQgTGlfOl2lXXKXw+yZu6bicycP+PUlM44CxBjCFD6aciKFT3LQ==", + "version": "5.102.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz", + "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==", "dev": true, "license": "MIT", "peer": true, @@ -10180,9 +10555,9 @@ "@webassemblyjs/wasm-parser": "^1.14.1", "acorn": "^8.15.0", "acorn-import-phases": "^1.0.3", - "browserslist": "^4.24.0", + "browserslist": "^4.26.3", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.2", + "enhanced-resolve": "^5.17.3", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", @@ -10192,10 +10567,10 @@ "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.2", - "tapable": "^2.1.1", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.1", + "watchpack": "^2.4.4", "webpack-sources": "^3.3.3" }, "bin": { @@ -10261,9 +10636,9 @@ } }, "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", - "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "peer": true, diff --git a/package.json b/package.json index 91a5e3a..2311eee 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "kill-ports": "bash kill-ports.sh", + "clean-start": "npm run kill-ports && npm run start:dev" }, "dependencies": { "@nestjs/common": "^10.0.0", @@ -26,21 +28,27 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^10.0.0", + "@nestjs/swagger": "^7.3.0", "@prisma/client": "^6.13.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cloudinary": "^1.41.3", + "multer": "^2.0.2", + "multer-storage-cloudinary": "^4.0.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", - "rxjs": "^7.8.1" + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", - "@types/express": "^5.0.0", + "@types/express": "^5.0.3", "@types/jest": "^29.5.2", + "@types/multer": "^2.0.0", "@types/node": "^20.3.1", "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.0", @@ -50,6 +58,7 @@ "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "jest": "^29.5.0", + "kill-port": "^2.0.1", "prettier": "^3.0.0", "prisma": "^6.13.0", "source-map-support": "^0.5.21", @@ -57,6 +66,7 @@ "ts-jest": "^29.1.0", "ts-loader": "^9.4.3", "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" }, diff --git a/prisma/migrations/20251010102032_add_book_price_location_transactions/migration.sql b/prisma/migrations/20251010102032_add_book_price_location_transactions/migration.sql new file mode 100644 index 0000000..6006e7c --- /dev/null +++ b/prisma/migrations/20251010102032_add_book_price_location_transactions/migration.sql @@ -0,0 +1,64 @@ +/* + Warnings: + + - Added the required column `location` to the `Book` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "public"."Book" ADD COLUMN "location" TEXT NOT NULL, +ADD COLUMN "price" INTEGER NOT NULL DEFAULT 10; + +-- CreateTable +CREATE TABLE "public"."Transaction" ( + "id" TEXT NOT NULL, + "bookId" TEXT NOT NULL, + "buyerId" TEXT NOT NULL, + "sellerId" TEXT, + "amount" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."AdminAction" ( + "id" TEXT NOT NULL, + "adminId" TEXT NOT NULL, + "targetType" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminAction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."Invite" ( + "id" TEXT NOT NULL, + "inviterId" TEXT NOT NULL, + "inviteeId" TEXT, + "code" TEXT NOT NULL, + "redeemed" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Invite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Invite_code_key" ON "public"."Invite"("code"); + +-- AddForeignKey +ALTER TABLE "public"."Transaction" ADD CONSTRAINT "Transaction_bookId_fkey" FOREIGN KEY ("bookId") REFERENCES "public"."Book"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Transaction" ADD CONSTRAINT "Transaction_buyerId_fkey" FOREIGN KEY ("buyerId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Transaction" ADD CONSTRAINT "Transaction_sellerId_fkey" FOREIGN KEY ("sellerId") REFERENCES "public"."User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."AdminAction" ADD CONSTRAINT "AdminAction_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Invite" ADD CONSTRAINT "Invite_inviterId_fkey" FOREIGN KEY ("inviterId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7d1a4ba..4daaa6c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,10 @@ model User { reviewsGiven Review[] @relation("Author") reviewsReceived Review[] @relation("Target") wallet Wallet? @relation("UserWallet") + buyerTransactions Transaction[] @relation("BuyerTransactions") + sellerTransactions Transaction[] @relation("SellerTransactions") + adminActions AdminAction[] @relation("UserAdminActions") + invitesSent Invite[] @relation("Inviter") } @@ -39,11 +43,14 @@ model Book { available Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + price Int @default(10) + location String ownerId String owner User @relation("UserBooks", fields: [ownerId], references: [id]) exchange Exchange? // Relación 1:1 opcional + transactions Transaction[] } model Wallet { @@ -104,6 +111,43 @@ model Review { target User @relation("Target", fields: [targetId], references: [id]) } +model Transaction { + id String @id @default(cuid()) + bookId String + buyerId String + sellerId String? + amount Int + createdAt DateTime @default(now()) + + book Book @relation(fields: [bookId], references: [id]) + buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id]) + seller User? @relation("SellerTransactions", fields: [sellerId], references: [id]) +} + +model AdminAction { + id String @id @default(cuid()) + adminId String + targetType String // "book", "exchange", "user" + targetId String + action String // "approve", "ban", "resolve" + notes String? + createdAt DateTime @default(now()) + admin User @relation("UserAdminActions", fields: [adminId], references: [id]) +} + +model Invite { + id String @id @default(cuid()) + inviterId String + inviteeId String? + code String @unique + redeemed Boolean @default(false) + createdAt DateTime @default(now()) + inviter User @relation("Inviter", fields: [inviterId], references: [id]) +} + + + + // Enums enum Role { diff --git a/prisma/seed.ts b/prisma/seed.ts index 295e1ed..0a826bc 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -51,7 +51,11 @@ async function main() { description: 'Distopía clásica sobre vigilancia y control.', imageUrl: 'https://example.com/1984.jpg', condition: BookCondition.GOOD, - ownerId: user1.id, + price: 10, + location: 'Tarragona', + owner: { + connect: { id: user1.id }, + }, }, }); @@ -63,7 +67,11 @@ async function main() { description: 'Un cuento filosófico para todas las edades.', imageUrl: 'https://example.com/principito.jpg', condition: BookCondition.FAIR, - ownerId: user2.id, + price: 10, + location: 'Tarragona', + owner: { + connect: { id: user2.id }, + }, }, }); diff --git a/src/app.module.ts b/src/app.module.ts index 0790c62..8a9b09a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -3,7 +3,8 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthModule } from './auth/auth.module'; import { ConfigModule } from '@nestjs/config'; -import { PrismaModule } from 'prisma/prisma.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { BookModule } from './books/books.module'; @Module({ imports: [ ConfigModule.forRoot({ @@ -11,6 +12,7 @@ import { PrismaModule } from 'prisma/prisma.module'; }), AuthModule, PrismaModule, + BookModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 77de0ac..7af8325 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,11 @@ import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBody, + ApiResponse, + ApiBearerAuth, +} from '@nestjs/swagger'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; @@ -6,18 +13,24 @@ import { Request } from 'express'; import { AuthGuard } from '@nestjs/passport'; import { User } from '@prisma/client'; +@ApiTags('auth') @Controller('auth') export class AuthController { constructor(private readonly authService: AuthService) {} @Post('register') + @ApiOperation({ summary: 'Registrar nuevo usuario' }) + @ApiBody({ type: RegisterDto }) + @ApiResponse({ status: 201, description: 'Usuario registrado correctamente' }) async register(@Body() registerDto: RegisterDto) { const user = await this.authService.register(registerDto); - // eslint-disable-next-line @typescript-eslint/no-unused-vars return user; } @Post('login') + @ApiOperation({ summary: 'Iniciar sesión' }) + @ApiBody({ type: LoginDto }) + @ApiResponse({ status: 200, description: 'Inicio de sesión exitoso' }) async login(@Body() loginDto: LoginDto) { const user = await this.authService.validateUser( loginDto.email, @@ -28,6 +41,9 @@ export class AuthController { @UseGuards(AuthGuard('jwt')) @Get('whoami') + @ApiOperation({ summary: 'Obtener usuario autenticado' }) + @ApiBearerAuth() + @ApiResponse({ status: 200, description: 'Usuario autenticado' }) async whoAmI(@Req() req: Request & { user: User }) { return req.user; } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index ae6bcdb..b999291 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,7 +5,7 @@ import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; import { PassportModule } from '@nestjs/passport'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { PrismaModule } from 'prisma/prisma.module'; +import { PrismaModule } from '../../prisma/prisma.module'; @Module({ imports: [ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 6504726..ffa3232 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -5,7 +5,7 @@ import { } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; -import { PrismaService } from 'prisma/prisma.service'; +import { PrismaService } from '../../prisma/prisma.service'; @Injectable() export class AuthService { @@ -40,7 +40,7 @@ export class AuthService { } async login(user: any) { - const payload = { sub: user.id, email: user.email, name: user.name }; + const payload = { sub: user.id, email: user.email, name: user.name, role: user.role }; return { token: this.jwtService.sign(payload) }; } } diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index ace8fc7..0a63fc8 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,9 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; import { IsEmail, IsString, MinLength } from 'class-validator'; export class LoginDto { + @ApiProperty({ example: 'email@gmail.com' }) @IsEmail() email: string; + @ApiProperty({ example: 'Password123' }) @IsString() @MinLength(6) password: string; diff --git a/src/auth/dto/register.dto.ts b/src/auth/dto/register.dto.ts index 2043ac4..e1ba396 100644 --- a/src/auth/dto/register.dto.ts +++ b/src/auth/dto/register.dto.ts @@ -1,12 +1,16 @@ import { IsEmail, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export class RegisterDto { + @ApiProperty({ example: 'Angela' }) @IsString() name: string; + @ApiProperty({ example: 'angela@example.com' }) @IsEmail() email: string; + @ApiProperty({ example: 'securePassword123' }) @IsString() @MinLength(6) password: string; diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 736e117..97e0a83 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -14,6 +14,6 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: any) { - return { id: payload.sub, email: payload.email, name: payload.name }; + return { id: payload.sub, email: payload.email, name: payload.name, role: payload.role }; } } diff --git a/src/books/book.controller.ts b/src/books/book.controller.ts new file mode 100644 index 0000000..b715a1d --- /dev/null +++ b/src/books/book.controller.ts @@ -0,0 +1,106 @@ +import { + Controller, + Post, + Body, + UploadedFile, + UseInterceptors, + Get, + Query, + Put, + Param, + Req, + UseGuards, + Delete, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { CreateBookDto } from './dto/create-book.dto'; +import { BookService } from './book.service'; +import { Express } from 'express'; +import { + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, + ApiConsumes, +} from '@nestjs/swagger'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; +import { PaginatedBooksResponseDto } from './dto/paginated-books-response.dto'; +import { storage } from 'src/cloudinary/cloudinary.config'; +import { UpdateBookDto } from './dto/update-book.dto'; +import { RequestWithUser } from 'src/common/types/request-with-user.interface'; +import { AuthGuard } from '@nestjs/passport'; + +@ApiTags('books') +@Controller('books') +export class BookController { + constructor(private readonly bookService: BookService) {} + + @Post('create') + @ApiOperation({ summary: 'Create new book' }) + @ApiResponse({ status: 201, description: 'Book create success' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + title: { type: 'string', example: 'El nombre del viento' }, + author: { type: 'string', example: 'Patrick Rothfuss' }, + isbn: { type: 'string', example: '9788498382540' }, + description: { type: 'string', example: 'Fantasía épica y poética' }, + condition: { type: 'string', enum: ['GOOD', 'EXCELLENT', 'POOR'] }, + location: { type: 'string', example: 'Tarragona' }, + price: { type: 'integer', example: 15 }, + ownerId: { type: 'string', example: 'cmgkw5oee0000o9f8gmgg1sgi' }, + image: { + type: 'string', + format: 'binary', + }, + }, + required: [ + 'title', + 'author', + 'isbn', + 'description', + 'condition', + 'location', + 'price', + 'ownerId', + 'image', + ], + }, + }) + @UseInterceptors(FileInterceptor('image', { storage })) + async createBook( + @UploadedFile() file: Express.Multer.File, + @Body() body: CreateBookDto, + ) { + return this.bookService.createBook({ ...body, imageUrl: file?.path }); + } + + @ApiResponse({ status: 200, type: PaginatedBooksResponseDto }) + @Get() + @ApiOperation({ summary: 'Get all books with pagination' }) + async findAll(@Query() paginationQuery: PaginationQueryDto) { + return this.bookService.findAll(paginationQuery); + } + + @Put(':id') + @UseGuards(AuthGuard('jwt')) + @ApiOperation({ summary: 'Edit book for ID' }) + @ApiResponse({ status: 200, description: 'Book updated success' }) + async updateBook( + @Param('id') id: string, + @Body() body: UpdateBookDto, + @Req() req: RequestWithUser, + ) { + return this.bookService.updateBook(id, body, req.user.id, req.user.role); + } + + @Delete(':id') + @UseGuards(AuthGuard('jwt')) + @ApiOperation({ summary: 'Delete book for ID' }) + @ApiResponse({ status: 200, description: 'Book deleted success' }) + async deleteBook(@Param('id') id: string, @Req() req: RequestWithUser) { + return this.bookService.deleteBook(id, req.user.id, req.user.role); + } +} diff --git a/src/books/book.service.ts b/src/books/book.service.ts new file mode 100644 index 0000000..1afb9f1 --- /dev/null +++ b/src/books/book.service.ts @@ -0,0 +1,130 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CreateBookDto } from './dto/create-book.dto'; +import { PaginationQueryDto } from './dto/pagination-query.dto'; +import { BookCondition } from '@prisma/client'; +import { UpdateBookDto } from './dto/update-book.dto'; + +@Injectable() +export class BookService { + constructor(private readonly prisma: PrismaService) {} + + async createBook(data: CreateBookDto & { imageUrl?: string }) { + const { ownerId, price, condition, ...rest } = data; + + const prismaCondition = + BookCondition[condition as keyof typeof BookCondition]; + + return this.prisma.book.create({ + data: { + ...rest, + condition: prismaCondition, + price: Number(price), + owner: { + connect: { id: ownerId }, + }, + }, + }); + } + + async findAll(paginationQuery?: PaginationQueryDto) { + const { page = 1, limit = 10 } = paginationQuery || {}; + const pageNum = Number(page); + const limitNum = Number(limit); + const skip = (pageNum - 1) * limitNum; + + const [books, totalBooks] = await this.prisma.$transaction([ + this.prisma.book.findMany({ + skip, + take: limitNum, + orderBy: { createdAt: 'desc' }, + include: { + owner: { + select: { + id: true, + name: true, + }, + }, + }, + }), + this.prisma.book.count(), + ]); + + const totalPages = Math.ceil(totalBooks / limitNum); + const hasNextPage = pageNum < totalPages; + const hasPreviousPage = pageNum > 1; + + return { + data: books, + pagination: { + currentPage: pageNum, + totalPages, + totalBooks, + limit: limitNum, + hasNextPage, + hasPreviousPage, + }, + }; + } + + async updateBook( + id: string, + data: UpdateBookDto, + userId: string, + role: 'USER' | 'ADMIN', + ) { + const book = await this.prisma.book.findUnique({ + where: { id }, + }); + + if (!book) { + throw new NotFoundException(`Book with ID ${id} not found`); + } + + const isOwner = book.ownerId === userId; + const isAdmin = role === 'ADMIN'; + + if (!isOwner && !isAdmin) { + throw new ForbiddenException('You are not permitted to edit this book.'); + } + + const updatedBook = await this.prisma.book.update({ + where: { id }, + data, + }); + + return { + message: 'Book updated successfully', + book: updatedBook, + }; + } + + async deleteBook(id: string, userId: string, role: 'USER' | 'ADMIN') { + const book = await this.prisma.book.findUnique({ + where: { id }, + }); + + if (!book) { + throw new NotFoundException(`Book with ID ${id} not found`); + } + + const isOwner = book.ownerId === userId; + const isAdmin = role === 'ADMIN'; + + if (!isOwner && !isAdmin) { + throw new ForbiddenException( + 'You are not permitted to delete this book.', + ); + } + await this.prisma.book.delete({ where: { id } }); + + return { + message: 'Book deleted successfully', + deletedBookId: id, + }; + } +} diff --git a/src/books/books.module.ts b/src/books/books.module.ts new file mode 100644 index 0000000..a27718d --- /dev/null +++ b/src/books/books.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { BookController } from './book.controller'; +import { BookService } from './book.service'; +import { PrismaModule } from '../../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [BookController], + providers: [BookService], +}) +export class BookModule {} diff --git a/src/books/dto/books-response.dto.ts b/src/books/dto/books-response.dto.ts new file mode 100644 index 0000000..d8dfe2f --- /dev/null +++ b/src/books/dto/books-response.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BookResponseDto { + @ApiProperty({ example: 'cmgkw5oee0000o9f8gmgg1sgi' }) + id: string; + + @ApiProperty({ example: 'El nombre del viento' }) + title: string; + + @ApiProperty({ example: 'Patrick Rothfuss' }) + author: string; + + @ApiProperty({ example: '9788498382540', required: false }) + isbn?: string; + + @ApiProperty({ example: 'Fantasía épica y poética', required: false }) + description?: string; + + @ApiProperty({ + example: 'https://res.cloudinary.com/.../image.jpg', + required: false, + }) + imageUrl?: string; + + @ApiProperty({ example: 'GOOD', enum: ['NEW', 'GOOD', 'FAIR', 'POOR'] }) + condition: string; + + @ApiProperty({ example: true }) + available: boolean; + + @ApiProperty({ example: 15 }) + price: number; + + @ApiProperty({ example: 'Tarragona' }) + location: string; + + @ApiProperty({ example: '2025-10-13T15:03:07.000Z' }) + createdAt: Date; + + @ApiProperty({ example: '2025-10-13T15:03:07.000Z' }) + updatedAt: Date; + + @ApiProperty({ + example: { + id: 'user123', + name: 'Ángela García', + }, + }) + owner: { + id: string; + name: string; + }; +} diff --git a/src/books/dto/create-book.dto.ts b/src/books/dto/create-book.dto.ts new file mode 100644 index 0000000..55546b8 --- /dev/null +++ b/src/books/dto/create-book.dto.ts @@ -0,0 +1,37 @@ +import { IsString, IsInt, IsEnum } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { BookConditionEnum } from 'src/common/enums'; + +export class CreateBookDto { + @ApiProperty({ example: 'El nombre del viento' }) + @IsString() + title: string; + + @ApiProperty({ example: 'Patrick Rothfuss' }) + @IsString() + author: string; + + @ApiProperty({ example: '9788498382540' }) + @IsString() + isbn: string; + + @ApiProperty({ example: 'Fantasía épica y poética' }) + @IsString() + description: string; + + @ApiProperty({ enum: BookConditionEnum, example: BookConditionEnum.GOOD }) + @IsEnum(BookConditionEnum) + condition: BookConditionEnum; + + @ApiProperty({ example: 'Tarragona' }) + @IsString() + location: string; + + @ApiProperty({ example: 15 }) + @IsInt() + price: number; + + @ApiProperty({ example: 'cmgkw5oee0000o9f8gmgg1sgi' }) + @IsString() + ownerId: string; +} diff --git a/src/books/dto/paginated-books-response.dto.ts b/src/books/dto/paginated-books-response.dto.ts new file mode 100644 index 0000000..ec3431e --- /dev/null +++ b/src/books/dto/paginated-books-response.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { BookResponseDto } from './books-response.dto'; + +export class PaginationInfoDto { + @ApiProperty({ example: 1, description: 'Página actual' }) + currentPage: number; + + @ApiProperty({ example: 5, description: 'Total de páginas disponibles' }) + totalPages: number; + + @ApiProperty({ example: 50, description: 'Total de libros' }) + totalBooks: number; + + @ApiProperty({ example: 10, description: 'Límite de resultados por página' }) + limit: number; + + @ApiProperty({ + example: true, + description: 'Indica si existe página siguiente', + }) + hasNextPage: boolean; + + @ApiProperty({ + example: false, + description: 'Indica si existe página anterior', + }) + hasPreviousPage: boolean; +} + +export class PaginatedBooksResponseDto { + @ApiProperty({ + type: [BookResponseDto], + description: 'Lista de libros en la página actual', + }) + data: BookResponseDto[]; + + @ApiProperty({ + type: PaginationInfoDto, + description: 'Información de paginación', + }) + pagination: PaginationInfoDto; +} diff --git a/src/books/dto/pagination-query.dto.ts b/src/books/dto/pagination-query.dto.ts new file mode 100644 index 0000000..1fae55d --- /dev/null +++ b/src/books/dto/pagination-query.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsInt, Min } from 'class-validator'; + +export class PaginationQueryDto { + @ApiPropertyOptional({ + example: 1, + description: 'Número de página (desde 1)', + }) + @Type(() => Number) + @IsOptional() + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + example: 10, + description: 'Cantidad de resultados por página', + }) + @Type(() => Number) + @IsOptional() + @IsInt() + @Min(1) + limit?: number = 10; +} diff --git a/src/books/dto/update-book.dto.ts b/src/books/dto/update-book.dto.ts new file mode 100644 index 0000000..93e17bb --- /dev/null +++ b/src/books/dto/update-book.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, OmitType } from '@nestjs/swagger'; +import { CreateBookDto } from './create-book.dto'; + +export class UpdateBookDto extends PartialType( + OmitType(CreateBookDto, ['ownerId']), +) {} diff --git a/src/cloudinary/cloudinary.config.ts b/src/cloudinary/cloudinary.config.ts new file mode 100644 index 0000000..8e4ffa1 --- /dev/null +++ b/src/cloudinary/cloudinary.config.ts @@ -0,0 +1,19 @@ +import { CloudinaryStorage } from 'multer-storage-cloudinary'; +import { v2 as cloudinary } from 'cloudinary'; +import * as dotenv from 'dotenv'; +dotenv.config(); // ← Asegura que las variables estén disponibles + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +export const storage = new CloudinaryStorage({ + cloudinary, + params: (req, file) => ({ + public_id: `${Date.now()}-${file.originalname.replace(/\.[^/.]+$/, '').replace(/\s+/g, '-')}`, + allowed_formats: ['jpg', 'png', 'jpeg'], + folder: 'bookloop', + }), +}); diff --git a/src/cloudinary/cloudinary.module.ts b/src/cloudinary/cloudinary.module.ts new file mode 100644 index 0000000..e1ba227 --- /dev/null +++ b/src/cloudinary/cloudinary.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CloudinaryProvider } from './cloudinary.provider'; + +@Module({ + providers: [CloudinaryProvider], + exports: ['Cloudinary'], +}) +export class CloudinaryModule {} diff --git a/src/cloudinary/cloudinary.provider.ts b/src/cloudinary/cloudinary.provider.ts new file mode 100644 index 0000000..71ef0cb --- /dev/null +++ b/src/cloudinary/cloudinary.provider.ts @@ -0,0 +1,13 @@ +import { v2 as cloudinary } from 'cloudinary'; + +export const CloudinaryProvider = { + provide: 'Cloudinary', + useFactory: () => { + cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, + }); + return cloudinary; + }, +}; diff --git a/src/common/enums/book-condition.enum.ts b/src/common/enums/book-condition.enum.ts new file mode 100644 index 0000000..191d460 --- /dev/null +++ b/src/common/enums/book-condition.enum.ts @@ -0,0 +1,6 @@ +export enum BookConditionEnum { + NEW = 'NEW', + GOOD = 'GOOD', + FAIR = 'FAIR', + POOR = 'POOR', +} diff --git a/src/common/enums/exchange-status.enum.ts b/src/common/enums/exchange-status.enum.ts new file mode 100644 index 0000000..d020774 --- /dev/null +++ b/src/common/enums/exchange-status.enum.ts @@ -0,0 +1,7 @@ +export enum ExchangeStatusEnum { + PENDING = 'PENDING', + ACCEPTED = 'ACCEPTED', + IN_TRANSIT = 'IN_TRANSIT', + DELIVERED = 'DELIVERED', + CANCELED = 'CANCELED', +} diff --git a/src/common/enums/index.ts b/src/common/enums/index.ts new file mode 100644 index 0000000..7d68750 --- /dev/null +++ b/src/common/enums/index.ts @@ -0,0 +1,4 @@ +export * from './book-condition.enum'; +export * from './role.enum'; +export * from './exchange-status.enum'; +export * from './movement-type.enum'; diff --git a/src/common/enums/movement-type.enum.ts b/src/common/enums/movement-type.enum.ts new file mode 100644 index 0000000..24af89c --- /dev/null +++ b/src/common/enums/movement-type.enum.ts @@ -0,0 +1,4 @@ +export enum MovementTypeEnum { + INCOME = 'INCOME', + EXPENSE = 'EXPENSE', +} diff --git a/src/common/enums/role.enum.ts b/src/common/enums/role.enum.ts new file mode 100644 index 0000000..1eb3dd6 --- /dev/null +++ b/src/common/enums/role.enum.ts @@ -0,0 +1,5 @@ +export enum RoleEnum { + USER = 'USER', + ADMIN = 'ADMIN', + BANNED = 'BANNED', +} diff --git a/src/common/types/request-with-user.interface.ts b/src/common/types/request-with-user.interface.ts new file mode 100644 index 0000000..a79bd5e --- /dev/null +++ b/src/common/types/request-with-user.interface.ts @@ -0,0 +1,8 @@ +import { Request } from 'express'; + +export interface RequestWithUser extends Request { + user: { + id: string; + role: 'USER' | 'ADMIN'; + }; +} diff --git a/src/main.ts b/src/main.ts index f992719..1519ece 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,20 @@ import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.module'; +import * as dotenv from 'dotenv'; +dotenv.config(); async function bootstrap() { const app = await NestFactory.create(AppModule); + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }) + ); + app.enableCors({ origin: 'http://localhost:3000', methods: ['GET', 'POST', 'PUT', 'DELETE'],