diff --git a/.gitignore b/.gitignore index 6308dfb..8e9667c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,11 @@ node_modules/ /dist/ /coverage/ +*.tsbuildinfo +prisma/*.js +prisma/*.js.map +prisma/*.d.ts + .env .env.* diff --git a/package-lock.json b/package-lock.json index 8e935fd..bb65354 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,18 @@ "dependencies": { "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@prisma/client": "^6.19.2", + "@types/bcrypt": "^6.0.0", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", + "bcrypt": "^6.0.0", "dotenv": "^17.2.3", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "prisma": "^6.19.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -2198,6 +2207,29 @@ } } }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, "node_modules/@nestjs/platform-express": { "version": "11.1.12", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", @@ -2627,11 +2659,19 @@ "@babel/types": "^7.28.2" } }, + "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==", + "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==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -2642,7 +2682,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2688,7 +2727,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -2700,7 +2738,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -2713,7 +2750,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -2761,6 +2797,16 @@ "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==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2768,35 +2814,77 @@ "dev": true, "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==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-local": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.38.tgz", + "integrity": "sha512-nsrW4A963lYE7lNTv9cr5WmiUD1ibYJvWrpE13oxApFsRt77b0RdtZvKbCdNIY4v/QZ6TRQWaDDEwV1kCTmcXg==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, "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==", - "dev": true, "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==", - "dev": true, "license": "MIT" }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -2806,7 +2894,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -3966,6 +4053,20 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4108,6 +4209,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4842,6 +4949,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7218,6 +7334,49 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7315,6 +7474,42 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7329,6 +7524,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -7701,6 +7902,15 @@ "dev": true, "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-emoji": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", @@ -7717,6 +7927,17 @@ "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", "license": "MIT" }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7973,6 +8194,53 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8056,6 +8324,11 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -8561,7 +8834,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -9663,7 +9935,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -9767,6 +10038,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "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", diff --git a/package.json b/package.json index 0eaae2a..e4347fb 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,18 @@ "dependencies": { "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", "@prisma/client": "^6.19.2", + "@types/bcrypt": "^6.0.0", + "@types/passport-jwt": "^4.0.1", + "@types/passport-local": "^1.0.38", + "bcrypt": "^6.0.0", "dotenv": "^17.2.3", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "prisma": "^6.19.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" diff --git a/prisma/migrations/20260122040127_init_domain/migration.sql b/prisma/migrations/20260122040127_init_domain/migration.sql new file mode 100644 index 0000000..b58ef1d --- /dev/null +++ b/prisma/migrations/20260122040127_init_domain/migration.sql @@ -0,0 +1,215 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('ADMIN', 'DIRECTOR', 'MECHANIC', 'WORKER', 'OTHER'); + +-- CreateEnum +CREATE TYPE "RepairStatus" AS ENUM ('RECEIVED', 'DIAGNOSIS', 'WAITING_APPROVAL', 'WAITING_PARTS', 'IN_WORK', 'ISSUED', 'CANCELED'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'OTHER', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Contractor" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "inn" TEXT, + "address" TEXT, + "phone" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Contractor_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Unit" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Unit_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UnitIdentifier" ( + "id" TEXT NOT NULL, + "unitId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "value" TEXT NOT NULL, + "validFrom" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "validTo" TIMESTAMP(3), + + CONSTRAINT "UnitIdentifier_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RepairOrder" ( + "id" TEXT NOT NULL, + "unitId" TEXT NOT NULL, + "contractorId" TEXT NOT NULL, + "status" "RepairStatus" NOT NULL DEFAULT 'RECEIVED', + "description" TEXT, + "issuedAt" TIMESTAMP(3), + "warrantyFrom" TIMESTAMP(3), + "warrantyTo" TIMESTAMP(3), + "warrantyDays" INTEGER, + "version" INTEGER NOT NULL DEFAULT 1, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RepairOrder_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RepairOrderAssignee" ( + "id" TEXT NOT NULL, + "repairOrderId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RepairOrderAssignee_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Trip" ( + "id" TEXT NOT NULL, + "contractorId" TEXT NOT NULL, + "serviceObjectId" TEXT, + "description" TEXT, + "scheduledFor" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Trip_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ServiceObject" ( + "id" TEXT NOT NULL, + "contractorId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "address" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ServiceObject_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Calculation" ( + "id" TEXT NOT NULL, + "repairOrderId" TEXT NOT NULL, + "templateId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Calculation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CalculationTemplate" ( + "id" TEXT NOT NULL, + "contractorId" TEXT, + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CalculationTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CalculationItem" ( + "id" TEXT NOT NULL, + "calculationId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "quantity" DOUBLE PRECISION NOT NULL, + "unit" TEXT NOT NULL, + "price" DOUBLE PRECISION, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CalculationItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "UnitIdentifier_unitId_type_value_validFrom_key" ON "UnitIdentifier"("unitId", "type", "value", "validFrom"); + +-- CreateIndex +CREATE INDEX "RepairOrder_status_idx" ON "RepairOrder"("status"); + +-- CreateIndex +CREATE INDEX "RepairOrder_contractorId_idx" ON "RepairOrder"("contractorId"); + +-- CreateIndex +CREATE INDEX "RepairOrder_unitId_idx" ON "RepairOrder"("unitId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RepairOrderAssignee_repairOrderId_userId_key" ON "RepairOrderAssignee"("repairOrderId", "userId"); + +-- CreateIndex +CREATE INDEX "Trip_scheduledFor_idx" ON "Trip"("scheduledFor"); + +-- CreateIndex +CREATE UNIQUE INDEX "Calculation_repairOrderId_key" ON "Calculation"("repairOrderId"); + +-- CreateIndex +CREATE INDEX "CalculationItem_calculationId_type_idx" ON "CalculationItem"("calculationId", "type"); + +-- AddForeignKey +ALTER TABLE "UnitIdentifier" ADD CONSTRAINT "UnitIdentifier_unitId_fkey" FOREIGN KEY ("unitId") REFERENCES "Unit"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RepairOrder" ADD CONSTRAINT "RepairOrder_unitId_fkey" FOREIGN KEY ("unitId") REFERENCES "Unit"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RepairOrder" ADD CONSTRAINT "RepairOrder_contractorId_fkey" FOREIGN KEY ("contractorId") REFERENCES "Contractor"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RepairOrderAssignee" ADD CONSTRAINT "RepairOrderAssignee_repairOrderId_fkey" FOREIGN KEY ("repairOrderId") REFERENCES "RepairOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RepairOrderAssignee" ADD CONSTRAINT "RepairOrderAssignee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Trip" ADD CONSTRAINT "Trip_contractorId_fkey" FOREIGN KEY ("contractorId") REFERENCES "Contractor"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Trip" ADD CONSTRAINT "Trip_serviceObjectId_fkey" FOREIGN KEY ("serviceObjectId") REFERENCES "ServiceObject"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ServiceObject" ADD CONSTRAINT "ServiceObject_contractorId_fkey" FOREIGN KEY ("contractorId") REFERENCES "Contractor"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Calculation" ADD CONSTRAINT "Calculation_repairOrderId_fkey" FOREIGN KEY ("repairOrderId") REFERENCES "RepairOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Calculation" ADD CONSTRAINT "Calculation_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "CalculationTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CalculationTemplate" ADD CONSTRAINT "CalculationTemplate_contractorId_fkey" FOREIGN KEY ("contractorId") REFERENCES "Contractor"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CalculationItem" ADD CONSTRAINT "CalculationItem_calculationId_fkey" FOREIGN KEY ("calculationId") REFERENCES "Calculation"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 30d035c..657c8f1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,3 +11,217 @@ model DbHealthCheck { id String @id @default(uuid()) createdAt DateTime @default(now()) } + +// ============================================================================= +// Пользователи и роли (RBAC) +// ============================================================================= + +enum Role { + ADMIN + DIRECTOR + MECHANIC + WORKER + OTHER +} + +model User { + id String @id @default(uuid()) + email String @unique + password String // bcrypt hash + firstName String + lastName String + role Role @default(OTHER) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + assignedToOrders RepairOrderAssignee[] +} + +// ============================================================================= +// Контрагенты +// ============================================================================= + +model Contractor { + id String @id @default(uuid()) + name String + inn String? // ИНН (опционально) + address String? + phone String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + repairOrders RepairOrder[] + trips Trip[] + serviceObjects ServiceObject[] + calculationTemplates CalculationTemplate[] +} + +// ============================================================================= +// Агрегаты (оборудование) +// ============================================================================= + +model Unit { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + identifiers UnitIdentifier[] + repairOrders RepairOrder[] +} + +model UnitIdentifier { + id String @id @default(uuid()) + unitId String + type String // 'SERIAL', 'INTERNAL', 'CUSTOMER' + value String + validFrom DateTime @default(now()) + validTo DateTime? + + // Relations + unit Unit @relation(fields: [unitId], references: [id], onDelete: Cascade) + + @@unique([unitId, type, value, validFrom]) +} + +// ============================================================================= +// Ремонты (RepairOrder) +// ============================================================================= + +enum RepairStatus { + RECEIVED + DIAGNOSIS + WAITING_APPROVAL + WAITING_PARTS + IN_WORK + ISSUED + CANCELED +} + +model RepairOrder { + id String @id @default(uuid()) + unitId String + contractorId String + status RepairStatus @default(RECEIVED) + description String? + issuedAt DateTime? // дата выдачи + warrantyFrom DateTime? // начало гарантии + warrantyTo DateTime? // конец гарантии + warrantyDays Int? // срок гарантии в днях + version Int @default(1) // optimistic locking + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + unit Unit @relation(fields: [unitId], references: [id]) + contractor Contractor @relation(fields: [contractorId], references: [id]) + assignees RepairOrderAssignee[] + calculation Calculation? + + @@index([status]) + @@index([contractorId]) + @@index([unitId]) +} + +model RepairOrderAssignee { + id String @id @default(uuid()) + repairOrderId String + userId String + assignedAt DateTime @default(now()) + + // Relations + repairOrder RepairOrder @relation(fields: [repairOrderId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([repairOrderId, userId]) +} + +// ============================================================================= +// Выезды +// ============================================================================= + +model Trip { + id String @id @default(uuid()) + contractorId String + serviceObjectId String? + description String? + scheduledFor DateTime + completedAt DateTime? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + contractor Contractor @relation(fields: [contractorId], references: [id]) + serviceObject ServiceObject? @relation(fields: [serviceObjectId], references: [id]) + + @@index([scheduledFor]) +} + +model ServiceObject { + id String @id @default(uuid()) + contractorId String + name String + address String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + contractor Contractor @relation(fields: [contractorId], references: [id]) + trips Trip[] +} + +// ============================================================================= +// Калькуляции (MVP без денег) +// ============================================================================= + +model Calculation { + id String @id @default(uuid()) + repairOrderId String @unique + templateId String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + repairOrder RepairOrder @relation(fields: [repairOrderId], references: [id], onDelete: Cascade) + template CalculationTemplate? @relation(fields: [templateId], references: [id]) + items CalculationItem[] +} + +model CalculationTemplate { + id String @id @default(uuid()) + contractorId String? + name String + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + contractor Contractor? @relation(fields: [contractorId], references: [id]) + calculations Calculation[] +} + +model CalculationItem { + id String @id @default(uuid()) + calculationId String + type String // 'WORK' or 'MATERIAL' + name String + quantity Float + unit String // единицы измерения + price Float? // отложено на будущее (MVP без денег) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + calculation Calculation @relation(fields: [calculationId], references: [id], onDelete: Cascade) + + @@index([calculationId, type]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..174d0f2 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,271 @@ +import { PrismaClient, Role, RepairStatus } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('Start seeding...'); + + // Clean existing data (optional, for dev) + // await prisma.calculationItem.deleteMany(); + // await prisma.calculation.deleteMany(); + // await prisma.calculationTemplate.deleteMany(); + // await prisma.repairOrderAssignee.deleteMany(); + // await prisma.repairOrder.deleteMany(); + // await prisma.unitIdentifier.deleteMany(); + // await prisma.unit.deleteMany(); + // await prisma.serviceObject.deleteMany(); + // await prisma.trip.deleteMany(); + // await prisma.contractor.deleteMany(); + // await prisma.user.deleteMany(); + + // Users + const adminPassword = await bcrypt.hash('admin123', 12); + const admin = await prisma.user.upsert({ + where: { email: 'admin@crm.local' }, + update: {}, + create: { + email: 'admin@crm.local', + password: adminPassword, + firstName: 'Admin', + lastName: 'User', + role: Role.ADMIN, + }, + }); + + const directorPassword = await bcrypt.hash('director123', 12); + const director = await prisma.user.upsert({ + where: { email: 'director@crm.local' }, + update: {}, + create: { + email: 'director@crm.local', + password: directorPassword, + firstName: 'Director', + lastName: 'User', + role: Role.DIRECTOR, + }, + }); + + const mechanicPassword = await bcrypt.hash('mechanic123', 12); + const mechanic = await prisma.user.upsert({ + where: { email: 'mechanic@crm.local' }, + update: {}, + create: { + email: 'mechanic@crm.local', + password: mechanicPassword, + firstName: 'Mechanic', + lastName: 'User', + role: Role.MECHANIC, + }, + }); + + const workerPassword = await bcrypt.hash('worker123', 12); + const worker = await prisma.user.upsert({ + where: { email: 'worker@crm.local' }, + update: {}, + create: { + email: 'worker@crm.local', + password: workerPassword, + firstName: 'Worker', + lastName: 'User', + role: Role.WORKER, + }, + }); + + console.log('Users created:', { admin: admin.email, director: director.email, mechanic: mechanic.email, worker: worker.email }); + + // Contractors + const contractor1 = await prisma.contractor.create({ + data: { + name: 'ООО "Ромашка"', + inn: '1234567890', + address: 'г. Москва, ул. Цветочная, д. 1', + phone: '+74951234567', + }, + }); + + const contractor2 = await prisma.contractor.create({ + data: { + name: 'ИП Петров', + inn: '0987654321', + address: 'г. Санкт-Петербург, пр. Невский, д. 42', + phone: '+78127654321', + }, + }); + + console.log('Contractors created:', contractor1.name, contractor2.name); + + // Units + Identifiers + const unit1 = await prisma.unit.create({ data: {} }); + await prisma.unitIdentifier.createMany({ + data: [ + { unitId: unit1.id, type: 'SERIAL', value: 'SN123456789' }, + { unitId: unit1.id, type: 'INTERNAL', value: 'INT001' }, + ], + }); + + const unit2 = await prisma.unit.create({ data: {} }); + await prisma.unitIdentifier.createMany({ + data: [ + { unitId: unit2.id, type: 'SERIAL', value: 'SN987654321' }, + { unitId: unit2.id, type: 'CUSTOMER', value: 'CUST-A42' }, + ], + }); + + console.log('Units created:', unit1.id, unit2.id); + + // RepairOrders + const repair1 = await prisma.repairOrder.create({ + data: { + unitId: unit1.id, + contractorId: contractor1.id, + status: RepairStatus.RECEIVED, + description: 'Не работает гидроцилиндр', + }, + }); + await prisma.repairOrderAssignee.create({ + data: { repairOrderId: repair1.id, userId: mechanic.id }, + }); + + const repair2 = await prisma.repairOrder.create({ + data: { + unitId: unit2.id, + contractorId: contractor2.id, + status: RepairStatus.IN_WORK, + description: 'Замена насоса', + }, + }); + await prisma.repairOrderAssignee.createMany({ + data: [ + { repairOrderId: repair2.id, userId: mechanic.id }, + { repairOrderId: repair2.id, userId: worker.id }, + ], + }); + + console.log('RepairOrders created:', repair1.id, repair2.id); + + // ServiceObjects + Trips + const serviceObj1 = await prisma.serviceObject.create({ + data: { + contractorId: contractor1.id, + name: 'Склад №1', + address: 'г. Москва, ул. Складская, д. 5', + }, + }); + + const serviceObj2 = await prisma.serviceObject.create({ + data: { + contractorId: contractor2.id, + name: 'Цех №3', + address: 'г. Санкт-Петербург, ул. Промышленная, д. 10', + }, + }); + + const trip1 = await prisma.trip.create({ + data: { + contractorId: contractor1.id, + serviceObjectId: serviceObj1.id, + description: 'Диагностика оборудования', + scheduledFor: new Date(Date.now() + 24 * 60 * 60 * 1000), // tomorrow + }, + }); + + const trip2 = await prisma.trip.create({ + data: { + contractorId: contractor2.id, + serviceObjectId: serviceObj2.id, + description: 'Монтаж гидроцилиндра', + scheduledFor: new Date(Date.now() + 48 * 60 * 60 * 1000), // day after tomorrow + }, + }); + + console.log('Trips created:', trip1.id, trip2.id); + + // Calculation Templates + const tmpl1 = await prisma.calculationTemplate.create({ + data: { + contractorId: contractor1.id, + name: 'Ремонт гидроцилиндров (базовый)', + }, + }); + + const tmpl2 = await prisma.calculationTemplate.create({ + data: { + name: 'Общая диагностика', + }, + }); + + console.log('CalculationTemplates created:', tmpl1.id, tmpl2.id); + + // Calculations + Items + const calc1 = await prisma.calculation.create({ + data: { + repairOrderId: repair1.id, + templateId: tmpl1.id, + }, + }); + await prisma.calculationItem.createMany({ + data: [ + { + calculationId: calc1.id, + type: 'WORK', + name: 'Диагностика', + quantity: 1, + unit: 'шт', + }, + { + calculationId: calc1.id, + type: 'WORK', + name: 'Ремонт поршня', + quantity: 2, + unit: 'шт', + }, + { + calculationId: calc1.id, + type: 'MATERIAL', + name: 'Сальник', + quantity: 4, + unit: 'шт', + }, + ], + }); + + const calc2 = await prisma.calculation.create({ + data: { + repairOrderId: repair2.id, + templateId: tmpl2.id, + }, + }); + await prisma.calculationItem.createMany({ + data: [ + { + calculationId: calc2.id, + type: 'WORK', + name: 'Замена насоса', + quantity: 1, + unit: 'шт', + }, + { + calculationId: calc2.id, + type: 'MATERIAL', + name: 'Насос НП-50', + quantity: 1, + unit: 'шт', + }, + ], + }); + + console.log('Calculations created:', calc1.id, calc2.id); + + console.log('Seeding finished.'); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/src/app.module.ts b/src/app.module.ts index afd3842..b331640 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,12 +1,31 @@ import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { HealthController } from './health.controller'; import { PrismaModule } from './prisma/prisma.module'; +import { UsersModule } from './users/users.module'; +import { ContractorsService } from './contractors/contractors.service'; +import { ContractorsModule } from './contractors/contractors.module'; +import { UnitsModule } from './units/units.module'; +import { RepairOrdersModule } from './repair-orders/repair-orders.module'; +import { AuthModule } from './auth/auth.module'; +import { TripsModule } from './trips/trips.module'; +import { CalculationsModule } from './calculations/calculations.module'; @Module({ - imports: [PrismaModule], + imports: [ + PassportModule, + PrismaModule, + UsersModule, + ContractorsModule, + UnitsModule, + RepairOrdersModule, + AuthModule, + TripsModule, + CalculationsModule, + ], controllers: [AppController, HealthController], - providers: [AppService], + providers: [AppService, ContractorsService], }) export class AppModule {} diff --git a/src/auth/auth.controller.spec.ts b/src/auth/auth.controller.spec.ts new file mode 100644 index 0000000..27a31e6 --- /dev/null +++ b/src/auth/auth.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthController } from './auth.controller'; + +describe('AuthController', () => { + let controller: AuthController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + }).compile(); + + controller = module.get(AuthController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts new file mode 100644 index 0000000..2b87f89 --- /dev/null +++ b/src/auth/auth.controller.ts @@ -0,0 +1,30 @@ +import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; +import { AuthService, type LoginDto, type AuthResponse } from './auth.service'; +import { Public } from './public.decorator'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('login') + @Public() + async login(@Body() loginDto: LoginDto): Promise { + try { + return await this.authService.login(loginDto); + } catch (e) { + if (e instanceof UnauthorizedException) throw e; + throw new UnauthorizedException('Login failed'); + } + } + + @Post('refresh') + @Public() + async refresh(@Body('refreshToken') refreshToken: string): Promise { + try { + return await this.authService.refresh(refreshToken); + } catch (e) { + if (e instanceof UnauthorizedException) throw e; + throw new UnauthorizedException('Refresh failed'); + } + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..15ff54a --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { AuthService } from './auth.service'; +import { AuthController } from './auth.controller'; +import { UsersModule } from '../users/users.module'; +import { JwtStrategy } from './jwt.strategy'; + +@Module({ + imports: [ + UsersModule, + PassportModule, + JwtModule.register({ + secret: process.env.JWT_SECRET || 'fallback-secret', + signOptions: { expiresIn: '15m' }, + }), + ], + providers: [AuthService, JwtStrategy], + controllers: [AuthController], + exports: [AuthService, JwtStrategy], +}) +export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..800ab66 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from './auth.service'; + +describe('AuthService', () => { + let service: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AuthService], + }).compile(); + + service = module.get(AuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..064141b --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,65 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { UsersService } from '../users/users.service'; +import * as bcrypt from 'bcrypt'; +import { User } from '@prisma/client'; + +export type LoginDto = { + email: string; + password: string; +}; + +export type AuthResponse = { + user: Omit; + accessToken: string; + refreshToken: string; +}; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + ) {} + + async validateUser(email: string, pass: string): Promise | null> { + const user = await this.usersService.findByEmail(email); + if (!user) return null; + + const isMatch = await bcrypt.compare(pass, user.password); + if (!isMatch) return null; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...result } = user; + return result; + } + + async login(loginDto: LoginDto): Promise { + const user = await this.validateUser(loginDto.email, loginDto.password); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + + const payload = { sub: user.id, email: user.email, role: user.role }; + const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' }); + const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' }); + + return { user, accessToken, refreshToken }; + } + + async refresh(refreshToken: string): Promise { + try { + const payload = this.jwtService.verify(refreshToken); + const user = await this.usersService.findOne(payload.sub); + if (!user) throw new UnauthorizedException('User not found'); + + const newPayload = { sub: user.id, email: user.email, role: user.role }; + const accessToken = this.jwtService.sign(newPayload, { expiresIn: '15m' }); + const newRefreshToken = this.jwtService.sign(newPayload, { expiresIn: '7d' }); + + return { user, accessToken, refreshToken: newRefreshToken }; + } catch { + throw new UnauthorizedException('Invalid refresh token'); + } + } +} diff --git a/src/auth/get-user.decorator.ts b/src/auth/get-user.decorator.ts new file mode 100644 index 0000000..e0cf612 --- /dev/null +++ b/src/auth/get-user.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Role } from '@prisma/client'; + +export const CurrentUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user as { userId: string; email: string; role: Role }; + }, +); diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..b48b501 --- /dev/null +++ b/src/auth/jwt-auth.guard.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from './public.decorator'; +import { UnauthorizedException } from '@nestjs/common'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + constructor(private reflector: Reflector) { + super(); + } + + canActivate(context: any) { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + return super.canActivate(context); + } + + handleRequest(err: any, user: any, info: any) { + if (err) { + throw err; + } + if (!user) { + const message = typeof info?.message === 'string' ? info.message : 'Unauthorized'; + throw new UnauthorizedException(message); + } + return user; + } +} diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts new file mode 100644 index 0000000..26f3856 --- /dev/null +++ b/src/auth/jwt.strategy.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Role } from '@prisma/client'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: process.env.JWT_SECRET || 'fallback-secret', + }); + } + + async validate(payload: { sub: string; email: string; role: Role }) { + return { userId: payload.sub, email: payload.email, role: payload.role }; + } +} diff --git a/src/auth/public.decorator.ts b/src/auth/public.decorator.ts new file mode 100644 index 0000000..b3845e1 --- /dev/null +++ b/src/auth/public.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/src/auth/rbac.decorator.ts b/src/auth/rbac.decorator.ts new file mode 100644 index 0000000..0d0c0d7 --- /dev/null +++ b/src/auth/rbac.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { Role } from '@prisma/client'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/rbac.guard.ts b/src/auth/rbac.guard.ts new file mode 100644 index 0000000..67376d0 --- /dev/null +++ b/src/auth/rbac.guard.ts @@ -0,0 +1,32 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Role } from '@prisma/client'; +import { ROLES_KEY } from './rbac.decorator'; +import { IS_PUBLIC_KEY } from './public.decorator'; + +@Injectable() +export class RbacGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles) { + return true; // No roles required + } + + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user.role === role); + } +} diff --git a/src/calculations/calculations.controller.spec.ts b/src/calculations/calculations.controller.spec.ts new file mode 100644 index 0000000..772f2ce --- /dev/null +++ b/src/calculations/calculations.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CalculationsController } from './calculations.controller'; + +describe('CalculationsController', () => { + let controller: CalculationsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CalculationsController], + }).compile(); + + controller = module.get(CalculationsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/calculations/calculations.controller.ts b/src/calculations/calculations.controller.ts new file mode 100644 index 0000000..dc4f682 --- /dev/null +++ b/src/calculations/calculations.controller.ts @@ -0,0 +1,152 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseUUIDPipe, + NotFoundException, + UseGuards, +} from '@nestjs/common'; +import { + CalculationsService, + type CreateCalculationDto, + type UpdateCalculationDto, + type CreateTemplateDto, + type UpdateTemplateDto, + type CreateItemDto, + type UpdateItemDto, +} from './calculations.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RbacGuard } from '../auth/rbac.guard'; +import { Roles } from '../auth/rbac.decorator'; +import { Role } from '@prisma/client'; + +@Controller('calculations') +@UseGuards(JwtAuthGuard, RbacGuard) +export class CalculationsController { + constructor(private readonly calculationsService: CalculationsService) {} + + // Calculation CRUD + @Post() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + async create(@Body() data: CreateCalculationDto) { + return this.calculationsService.create(data); + } + + @Get() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findAll() { + return this.calculationsService.findAll(); + } + + @Get(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findOne(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.calculationsService.findOne(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Calculation not found'); + } + } + + @Patch(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + update(@Param('id', ParseUUIDPipe) id: string, @Body() data: UpdateCalculationDto) { + try { + return this.calculationsService.update(id, data); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Calculation not found'); + } + } + + @Delete(':id') + @Roles(Role.ADMIN) + remove(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.calculationsService.remove(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Calculation not found'); + } + } + + // Template CRUD + @Post('templates') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + async createTemplate(@Body() data: CreateTemplateDto) { + return this.calculationsService.createTemplate(data); + } + + @Get('templates') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findAllTemplates() { + return this.calculationsService.findAllTemplates(); + } + + @Get('templates/:id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findTemplate(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.calculationsService.findTemplate(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('CalculationTemplate not found'); + } + } + + @Patch('templates/:id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + updateTemplate(@Param('id', ParseUUIDPipe) id: string, @Body() data: UpdateTemplateDto) { + try { + return this.calculationsService.updateTemplate(id, data); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('CalculationTemplate not found'); + } + } + + @Delete('templates/:id') + @Roles(Role.ADMIN) + removeTemplate(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.calculationsService.removeTemplate(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('CalculationTemplate not found'); + } + } + + // Item CRUD + @Post('items') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + async addItem(@Body() data: CreateItemDto) { + return this.calculationsService.addItem(data); + } + + @Patch('items/:id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + updateItem(@Param('id', ParseUUIDPipe) id: string, @Body() data: UpdateItemDto) { + try { + return this.calculationsService.updateItem(id, data); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('CalculationItem not found'); + } + } + + @Delete('items/:id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + removeItem(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.calculationsService.removeItem(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('CalculationItem not found'); + } + } +} diff --git a/src/calculations/calculations.module.ts b/src/calculations/calculations.module.ts new file mode 100644 index 0000000..2bde95b --- /dev/null +++ b/src/calculations/calculations.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CalculationsService } from './calculations.service'; +import { CalculationsController } from './calculations.controller'; + +@Module({ + providers: [CalculationsService], + controllers: [CalculationsController], + exports: [CalculationsService], +}) +export class CalculationsModule {} diff --git a/src/calculations/calculations.service.spec.ts b/src/calculations/calculations.service.spec.ts new file mode 100644 index 0000000..8f55f8c --- /dev/null +++ b/src/calculations/calculations.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CalculationsService } from './calculations.service'; + +describe('CalculationsService', () => { + let service: CalculationsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CalculationsService], + }).compile(); + + service = module.get(CalculationsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/calculations/calculations.service.ts b/src/calculations/calculations.service.ts new file mode 100644 index 0000000..6668b65 --- /dev/null +++ b/src/calculations/calculations.service.ts @@ -0,0 +1,138 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Calculation, CalculationTemplate, CalculationItem } from '@prisma/client'; + +export type CreateCalculationDto = { + repairOrderId: string; + templateId?: string; +}; + +export type UpdateCalculationDto = Partial & { + isActive?: boolean; +}; + +export type CreateTemplateDto = { + contractorId?: string; + name: string; +}; + +export type UpdateTemplateDto = Partial & { + isActive?: boolean; +}; + +export type CreateItemDto = { + calculationId: string; + type: 'WORK' | 'MATERIAL'; + name: string; + quantity: number; + unit: string; + price?: number; +}; + +export type UpdateItemDto = Partial & { + isActive?: boolean; +}; + +@Injectable() +export class CalculationsService { + constructor(private readonly prisma: PrismaService) {} + + // Template CRUD + async createTemplate(data: CreateTemplateDto): Promise { + if (data.contractorId) { + const contractor = await this.prisma.contractor.findUnique({ where: { id: data.contractorId } }); + if (!contractor) throw new NotFoundException('Contractor not found'); + } + + return this.prisma.calculationTemplate.create({ data }); + } + + async findAllTemplates(): Promise { + return this.prisma.calculationTemplate.findMany({ where: { isActive: true } }); + } + + async findTemplate(id: string): Promise { + const tmpl = await this.prisma.calculationTemplate.findUnique({ where: { id, isActive: true } }); + if (!tmpl) throw new NotFoundException('CalculationTemplate not found'); + return tmpl; + } + + async updateTemplate(id: string, data: UpdateTemplateDto): Promise { + const existing = await this.prisma.calculationTemplate.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('CalculationTemplate not found'); + + return this.prisma.calculationTemplate.update({ where: { id }, data }); + } + + async removeTemplate(id: string): Promise { + const existing = await this.prisma.calculationTemplate.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('CalculationTemplate not found'); + + await this.prisma.calculationTemplate.update({ where: { id }, data: { isActive: false } }); + } + + // Calculation CRUD + async create(data: CreateCalculationDto): Promise { + const order = await this.prisma.repairOrder.findUnique({ where: { id: data.repairOrderId } }); + if (!order) throw new NotFoundException('RepairOrder not found'); + if (data.templateId) { + const tmpl = await this.prisma.calculationTemplate.findUnique({ where: { id: data.templateId } }); + if (!tmpl) throw new NotFoundException('CalculationTemplate not found'); + } + + return this.prisma.calculation.create({ data }); + } + + async findAll(): Promise { + return this.prisma.calculation.findMany({ + where: { isActive: true }, + include: { repairOrder: true, template: true, items: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findOne(id: string): Promise { + const calc = await this.prisma.calculation.findUnique({ + where: { id, isActive: true }, + include: { repairOrder: true, template: true, items: true }, + }); + if (!calc) throw new NotFoundException('Calculation not found'); + return calc; + } + + async update(id: string, data: UpdateCalculationDto): Promise { + const existing = await this.prisma.calculation.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('Calculation not found'); + + return this.prisma.calculation.update({ where: { id }, data }); + } + + async remove(id: string): Promise { + const existing = await this.prisma.calculation.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('Calculation not found'); + + await this.prisma.calculation.update({ where: { id }, data: { isActive: false } }); + } + + // Item CRUD + async addItem(data: CreateItemDto): Promise { + const calc = await this.prisma.calculation.findUnique({ where: { id: data.calculationId } }); + if (!calc) throw new NotFoundException('Calculation not found'); + + return this.prisma.calculationItem.create({ data }); + } + + async updateItem(id: string, data: UpdateItemDto): Promise { + const existing = await this.prisma.calculationItem.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('CalculationItem not found'); + + return this.prisma.calculationItem.update({ where: { id }, data }); + } + + async removeItem(id: string): Promise { + const existing = await this.prisma.calculationItem.findUnique({ where: { id } }); + if (!existing) throw new NotFoundException('CalculationItem not found'); + + await this.prisma.calculationItem.update({ where: { id }, data: { isActive: false } }); + } +} diff --git a/src/contractors/contractors.controller.spec.ts b/src/contractors/contractors.controller.spec.ts new file mode 100644 index 0000000..0ba4f7f --- /dev/null +++ b/src/contractors/contractors.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ContractorsController } from './contractors.controller'; + +describe('ContractorsController', () => { + let controller: ContractorsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ContractorsController], + }).compile(); + + controller = module.get(ContractorsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/contractors/contractors.controller.ts b/src/contractors/contractors.controller.ts new file mode 100644 index 0000000..f32c9b9 --- /dev/null +++ b/src/contractors/contractors.controller.ts @@ -0,0 +1,68 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseUUIDPipe, + NotFoundException, + UseGuards, +} from '@nestjs/common'; +import { ContractorsService, type CreateContractorDto, type UpdateContractorDto } from './contractors.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RbacGuard } from '../auth/rbac.guard'; +import { Roles } from '../auth/rbac.decorator'; +import { Role } from '@prisma/client'; + +@Controller('contractors') +@UseGuards(JwtAuthGuard, RbacGuard) +export class ContractorsController { + constructor(private readonly contractorsService: ContractorsService) {} + + @Post() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + async create(@Body() createContractorDto: CreateContractorDto) { + return this.contractorsService.create(createContractorDto); + } + + @Get() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findAll() { + return this.contractorsService.findAll(); + } + + @Get(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findOne(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.contractorsService.findOne(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Contractor not found'); + } + } + + @Patch(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + update(@Param('id', ParseUUIDPipe) id: string, @Body() updateContractorDto: UpdateContractorDto) { + try { + return this.contractorsService.update(id, updateContractorDto); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Contractor not found'); + } + } + + @Delete(':id') + @Roles(Role.ADMIN) + remove(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.contractorsService.remove(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Contractor not found'); + } + } +} diff --git a/src/contractors/contractors.module.ts b/src/contractors/contractors.module.ts new file mode 100644 index 0000000..69c66f8 --- /dev/null +++ b/src/contractors/contractors.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ContractorsService } from './contractors.service'; +import { ContractorsController } from './contractors.controller'; + +@Module({ + controllers: [ContractorsController], + providers: [ContractorsService], + exports: [ContractorsService], +}) +export class ContractorsModule {} diff --git a/src/contractors/contractors.service.spec.ts b/src/contractors/contractors.service.spec.ts new file mode 100644 index 0000000..daf8c9b --- /dev/null +++ b/src/contractors/contractors.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ContractorsService } from './contractors.service'; + +describe('ContractorsService', () => { + let service: ContractorsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ContractorsService], + }).compile(); + + service = module.get(ContractorsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/contractors/contractors.service.ts b/src/contractors/contractors.service.ts new file mode 100644 index 0000000..090ad55 --- /dev/null +++ b/src/contractors/contractors.service.ts @@ -0,0 +1,49 @@ +import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Contractor } from '@prisma/client'; + +export type CreateContractorDto = { + name: string; + inn?: string; + address?: string; + phone?: string; +}; + +export type UpdateContractorDto = Partial & { + isActive?: boolean; +}; + +@Injectable() +export class ContractorsService { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateContractorDto): Promise { + const contractor = await this.prisma.contractor.create({ data }); + return contractor; + } + + async findAll(): Promise { + return this.prisma.contractor.findMany({ where: { isActive: true } }); + } + + async findOne(id: string): Promise { + const contractor = await this.prisma.contractor.findUnique({ where: { id, isActive: true } }); + if (!contractor) throw new NotFoundException('Contractor not found'); + return contractor; + } + + async update(id: string, data: UpdateContractorDto): Promise { + const existing = await this.prisma.contractor.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('Contractor not found'); + + return this.prisma.contractor.update({ where: { id }, data }); + } + + async remove(id: string): Promise { + const existing = await this.prisma.contractor.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('Contractor not found'); + + // Soft delete + await this.prisma.contractor.update({ where: { id }, data: { isActive: false } }); + } +} diff --git a/src/health.controller.ts b/src/health.controller.ts index 96e4e71..6a45865 100644 --- a/src/health.controller.ts +++ b/src/health.controller.ts @@ -1,39 +1,36 @@ import { Controller, Get, Res } from '@nestjs/common'; -import type { Response } from 'express'; +import { type Response } from 'express'; import { PrismaService } from './prisma/prisma.service'; +import { Public } from './auth/public.decorator'; -@Controller('health') +@Controller() export class HealthController { constructor(private readonly prisma: PrismaService) {} - @Get() - async getHealth(@Res() res: Response) { - const payload = { - status: 'ok', - timestamp: new Date().toISOString(), - service: 'crm-backend', - version: - process.env.APP_VERSION ?? process.env.npm_package_version ?? 'unknown', - uptimeSeconds: Math.floor(process.uptime()), - db: { - ok: true, - }, - }; - + @Get('health') + @Public() + async health(@Res() res: Response) { + const start = Date.now(); + let dbOk = false; try { await this.prisma.$queryRaw`SELECT 1`; - } catch (e: any) { - payload.status = 'degraded'; - payload.db.ok = false; - res - .status(503) - .type('application/json; charset=utf-8') - .send(`${JSON.stringify(payload)}\n`); - return; + dbOk = true; + } catch { + dbOk = false; } + const uptimeMs = process.uptime() * 1000; + const payload = { + status: dbOk ? 'ok' : 'error', + timestamp: new Date().toISOString(), + service: 'crm-backend', + version: process.env.npm_package_version || 'unknown', + uptimeSeconds: Math.floor(uptimeMs / 1000), + db: { ok: dbOk }, + }; + const statusCode = dbOk ? 200 : 503; res - .status(200) + .status(statusCode) .type('application/json; charset=utf-8') .send(`${JSON.stringify(payload)}\n`); } diff --git a/src/main.ts b/src/main.ts index 290be7b..4ca9b50 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,13 @@ import { NestFactory } from '@nestjs/core'; -import { AppModule } from './app.module'; import * as dotenv from 'dotenv'; +dotenv.config({ path: '/opt/crm/.env' }); + +// Load AppModule after env is loaded, so JwtModule/JwtStrategy read correct secrets. +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { AppModule } = require('./app.module'); + async function bootstrap() { - dotenv.config({ path: '/opt/crm/.env' }); const app = await NestFactory.create(AppModule); await app.listen(process.env.PORT ?? 3000); } diff --git a/src/repair-orders/repair-orders.controller.spec.ts b/src/repair-orders/repair-orders.controller.spec.ts new file mode 100644 index 0000000..4c36299 --- /dev/null +++ b/src/repair-orders/repair-orders.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RepairOrdersController } from './repair-orders.controller'; + +describe('RepairOrdersController', () => { + let controller: RepairOrdersController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RepairOrdersController], + }).compile(); + + controller = module.get(RepairOrdersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/repair-orders/repair-orders.controller.ts b/src/repair-orders/repair-orders.controller.ts new file mode 100644 index 0000000..af8ba27 --- /dev/null +++ b/src/repair-orders/repair-orders.controller.ts @@ -0,0 +1,107 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseUUIDPipe, + NotFoundException, + ConflictException, + UseGuards, +} from '@nestjs/common'; +import { RepairOrdersService, type CreateRepairOrderDto, type UpdateRepairOrderDto } from './repair-orders.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RbacGuard } from '../auth/rbac.guard'; +import { Roles } from '../auth/rbac.decorator'; +import { CurrentUser } from '../auth/get-user.decorator'; +import { Role } from '@prisma/client'; + +@Controller('repair-orders') +@UseGuards(JwtAuthGuard, RbacGuard) +export class RepairOrdersController { + constructor(private readonly repairOrdersService: RepairOrdersService) {} + + @Post() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + async create(@Body() createRepairOrderDto: CreateRepairOrderDto, @CurrentUser() user: { role: Role }) { + return this.repairOrdersService.create(createRepairOrderDto, user.role); + } + + @Get() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findAll() { + return this.repairOrdersService.findAll(); + } + + @Get(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findOne(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.repairOrdersService.findOne(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('RepairOrder not found'); + } + } + + @Patch(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() updateRepairOrderDto: UpdateRepairOrderDto, + @CurrentUser() user: { role: Role }, + ) { + try { + return this.repairOrdersService.update(id, updateRepairOrderDto, user.role); + } catch (e) { + if (e instanceof NotFoundException || e instanceof ConflictException) throw e; + throw new NotFoundException('RepairOrder not found'); + } + } + + @Delete(':id') + @Roles(Role.ADMIN) + remove(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.repairOrdersService.remove(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('RepairOrder not found'); + } + } + + // Assignees + @Post(':id/assignees/:userId') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + async addAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Param('userId', ParseUUIDPipe) userId: string, + @CurrentUser() user: { role: Role }, + ) { + try { + await this.repairOrdersService.addAssignee(id, userId, user.role); + return { success: true }; + } catch (e) { + if (e instanceof NotFoundException || e instanceof ConflictException) throw e; + throw new NotFoundException('RepairOrder or User not found'); + } + } + + @Delete(':id/assignees/:userId') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + async removeAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Param('userId', ParseUUIDPipe) userId: string, + @CurrentUser() user: { role: Role }, + ) { + try { + await this.repairOrdersService.removeAssignee(id, userId, user.role); + return { success: true }; + } catch (e) { + if (e instanceof NotFoundException || e instanceof ConflictException) throw e; + throw new NotFoundException('RepairOrder or User not found'); + } + } +} diff --git a/src/repair-orders/repair-orders.module.ts b/src/repair-orders/repair-orders.module.ts new file mode 100644 index 0000000..08fe686 --- /dev/null +++ b/src/repair-orders/repair-orders.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { RepairOrdersService } from './repair-orders.service'; +import { RepairOrdersController } from './repair-orders.controller'; + +@Module({ + providers: [RepairOrdersService], + controllers: [RepairOrdersController], + exports: [RepairOrdersService], +}) +export class RepairOrdersModule {} diff --git a/src/repair-orders/repair-orders.service.spec.ts b/src/repair-orders/repair-orders.service.spec.ts new file mode 100644 index 0000000..9de602f --- /dev/null +++ b/src/repair-orders/repair-orders.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RepairOrdersService } from './repair-orders.service'; + +describe('RepairOrdersService', () => { + let service: RepairOrdersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RepairOrdersService], + }).compile(); + + service = module.get(RepairOrdersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/repair-orders/repair-orders.service.ts b/src/repair-orders/repair-orders.service.ts new file mode 100644 index 0000000..b1ab4e5 --- /dev/null +++ b/src/repair-orders/repair-orders.service.ts @@ -0,0 +1,112 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { RepairOrder, RepairStatus, Role } from '@prisma/client'; + +export type CreateRepairOrderDto = { + unitId: string; + contractorId: string; + description?: string; +}; + +export type UpdateRepairOrderDto = Partial< + Omit & { + status?: RepairStatus; + issuedAt?: Date; + warrantyFrom?: Date; + warrantyTo?: Date; + warrantyDays?: number; + version?: number; // optimistic locking + } +>; + +@Injectable() +export class RepairOrdersService { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateRepairOrderDto, creatorRole: Role): Promise { + // Validate unit and contractor exist + const unit = await this.prisma.unit.findUnique({ where: { id: data.unitId } }); + if (!unit) throw new NotFoundException('Unit not found'); + const contractor = await this.prisma.contractor.findUnique({ where: { id: data.contractorId } }); + if (!contractor) throw new NotFoundException('Contractor not found'); + + return this.prisma.repairOrder.create({ data }); + } + + async findAll(): Promise { + return this.prisma.repairOrder.findMany({ + where: { isActive: true }, + include: { unit: { include: { identifiers: true } }, contractor: true, assignees: { include: { user: true } } }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findOne(id: string): Promise { + const order = await this.prisma.repairOrder.findUnique({ + where: { id, isActive: true }, + include: { unit: { include: { identifiers: true } }, contractor: true, assignees: { include: { user: true } } }, + }); + if (!order) throw new NotFoundException('RepairOrder not found'); + return order; + } + + async update(id: string, data: UpdateRepairOrderDto, updaterRole: Role): Promise { + const existing = await this.prisma.repairOrder.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('RepairOrder not found'); + + // Optimistic locking + if (data.version !== undefined && data.version !== existing.version) { + throw new ConflictException('RepairOrder was modified by another user'); + } + + // Auto-increment version + const newData = { ...data, version: existing.version + 1 }; + + // Business rules: only MECHANIC+ can change status + if (data.status && updaterRole === Role.WORKER) { + throw new ConflictException('Workers cannot change repair status'); + } + + return this.prisma.repairOrder.update({ where: { id }, data: newData }); + } + + async remove(id: string): Promise { + const existing = await this.prisma.repairOrder.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('RepairOrder not found'); + + // Soft delete + await this.prisma.repairOrder.update({ where: { id }, data: { isActive: false } }); + } + + // Assignees + async addAssignee(repairOrderId: string, userId: string, requestorRole: Role): Promise { + const order = await this.prisma.repairOrder.findUnique({ where: { id: repairOrderId, isActive: true } }); + if (!order) throw new NotFoundException('RepairOrder not found'); + + const user = await this.prisma.user.findUnique({ where: { id: userId, isActive: true } }); + if (!user) throw new NotFoundException('User not found'); + + // Only MECHANIC+ can assign + if (requestorRole === Role.WORKER) { + throw new ConflictException('Workers cannot assign users to repairs'); + } + + await this.prisma.repairOrderAssignee.create({ + data: { repairOrderId, userId }, + }); + } + + async removeAssignee(repairOrderId: string, userId: string, requestorRole: Role): Promise { + const assignee = await this.prisma.repairOrderAssignee.findUnique({ + where: { repairOrderId_userId: { repairOrderId, userId } }, + }); + if (!assignee) throw new NotFoundException('Assignee not found'); + + // Only MECHANIC+ can unassign + if (requestorRole === Role.WORKER) { + throw new ConflictException('Workers cannot unassign users from repairs'); + } + + await this.prisma.repairOrderAssignee.delete({ where: { id: assignee.id } }); + } +} diff --git a/src/trips/trips.controller.spec.ts b/src/trips/trips.controller.spec.ts new file mode 100644 index 0000000..1e9b295 --- /dev/null +++ b/src/trips/trips.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripsController } from './trips.controller'; + +describe('TripsController', () => { + let controller: TripsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TripsController], + }).compile(); + + controller = module.get(TripsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/trips/trips.controller.ts b/src/trips/trips.controller.ts new file mode 100644 index 0000000..01adb20 --- /dev/null +++ b/src/trips/trips.controller.ts @@ -0,0 +1,121 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseUUIDPipe, + NotFoundException, + UseGuards, +} from '@nestjs/common'; +import { + TripsService, + type CreateTripDto, + type UpdateTripDto, + type CreateServiceObjectDto, + type UpdateServiceObjectDto, +} from './trips.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RbacGuard } from '../auth/rbac.guard'; +import { Roles } from '../auth/rbac.decorator'; +import { Role } from '@prisma/client'; + +@Controller('trips') +@UseGuards(JwtAuthGuard, RbacGuard) +export class TripsController { + constructor(private readonly tripsService: TripsService) {} + + // Trip CRUD + @Post() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + async create(@Body() createTripDto: CreateTripDto) { + return this.tripsService.create(createTripDto); + } + + @Get() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findAll() { + return this.tripsService.findAll(); + } + + @Get(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findOne(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.tripsService.findOne(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Trip not found'); + } + } + + @Patch(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + update(@Param('id', ParseUUIDPipe) id: string, @Body() updateTripDto: UpdateTripDto) { + try { + return this.tripsService.update(id, updateTripDto); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Trip not found'); + } + } + + @Delete(':id') + @Roles(Role.ADMIN) + remove(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.tripsService.remove(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Trip not found'); + } + } + + // ServiceObject CRUD + @Post('service-objects') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + async createServiceObject(@Body() data: CreateServiceObjectDto) { + return this.tripsService.createServiceObject(data); + } + + @Get('service-objects') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findAllServiceObjects() { + return this.tripsService.findAllServiceObjects(); + } + + @Get('service-objects/:id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findServiceObject(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.tripsService.findServiceObject(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('ServiceObject not found'); + } + } + + @Patch('service-objects/:id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + updateServiceObject(@Param('id', ParseUUIDPipe) id: string, @Body() data: UpdateServiceObjectDto) { + try { + return this.tripsService.updateServiceObject(id, data); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('ServiceObject not found'); + } + } + + @Delete('service-objects/:id') + @Roles(Role.ADMIN) + removeServiceObject(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.tripsService.removeServiceObject(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('ServiceObject not found'); + } + } +} diff --git a/src/trips/trips.module.ts b/src/trips/trips.module.ts new file mode 100644 index 0000000..967ac80 --- /dev/null +++ b/src/trips/trips.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { TripsService } from './trips.service'; +import { TripsController } from './trips.controller'; + +@Module({ + providers: [TripsService], + controllers: [TripsController], + exports: [TripsService], +}) +export class TripsModule {} diff --git a/src/trips/trips.service.spec.ts b/src/trips/trips.service.spec.ts new file mode 100644 index 0000000..b5233de --- /dev/null +++ b/src/trips/trips.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripsService } from './trips.service'; + +describe('TripsService', () => { + let service: TripsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TripsService], + }).compile(); + + service = module.get(TripsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/trips/trips.service.ts b/src/trips/trips.service.ts new file mode 100644 index 0000000..5383d2f --- /dev/null +++ b/src/trips/trips.service.ts @@ -0,0 +1,108 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Trip, ServiceObject } from '@prisma/client'; + +export type CreateTripDto = { + contractorId: string; + serviceObjectId?: string; + description?: string; + scheduledFor: Date; +}; + +export type UpdateTripDto = Partial< + Omit & { + scheduledFor?: Date; + completedAt?: Date; + isActive?: boolean; + } +>; + +export type CreateServiceObjectDto = { + contractorId: string; + name: string; + address?: string; +}; + +export type UpdateServiceObjectDto = Partial & { + isActive?: boolean; +}; + +@Injectable() +export class TripsService { + constructor(private readonly prisma: PrismaService) {} + + // ServiceObject CRUD + async createServiceObject(data: CreateServiceObjectDto): Promise { + const contractor = await this.prisma.contractor.findUnique({ where: { id: data.contractorId } }); + if (!contractor) throw new NotFoundException('Contractor not found'); + + return this.prisma.serviceObject.create({ data }); + } + + async findAllServiceObjects(): Promise { + return this.prisma.serviceObject.findMany({ where: { isActive: true } }); + } + + async findServiceObject(id: string): Promise { + const obj = await this.prisma.serviceObject.findUnique({ where: { id, isActive: true } }); + if (!obj) throw new NotFoundException('ServiceObject not found'); + return obj; + } + + async updateServiceObject(id: string, data: UpdateServiceObjectDto): Promise { + const existing = await this.prisma.serviceObject.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('ServiceObject not found'); + + return this.prisma.serviceObject.update({ where: { id }, data }); + } + + async removeServiceObject(id: string): Promise { + const existing = await this.prisma.serviceObject.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('ServiceObject not found'); + + await this.prisma.serviceObject.update({ where: { id }, data: { isActive: false } }); + } + + // Trip CRUD + async create(data: CreateTripDto): Promise { + const contractor = await this.prisma.contractor.findUnique({ where: { id: data.contractorId } }); + if (!contractor) throw new NotFoundException('Contractor not found'); + if (data.serviceObjectId) { + const obj = await this.prisma.serviceObject.findUnique({ where: { id: data.serviceObjectId } }); + if (!obj) throw new NotFoundException('ServiceObject not found'); + } + + return this.prisma.trip.create({ data }); + } + + async findAll(): Promise { + return this.prisma.trip.findMany({ + where: { isActive: true }, + include: { contractor: true, serviceObject: true }, + orderBy: { scheduledFor: 'asc' }, + }); + } + + async findOne(id: string): Promise { + const trip = await this.prisma.trip.findUnique({ + where: { id, isActive: true }, + include: { contractor: true, serviceObject: true }, + }); + if (!trip) throw new NotFoundException('Trip not found'); + return trip; + } + + async update(id: string, data: UpdateTripDto): Promise { + const existing = await this.prisma.trip.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('Trip not found'); + + return this.prisma.trip.update({ where: { id }, data }); + } + + async remove(id: string): Promise { + const existing = await this.prisma.trip.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('Trip not found'); + + await this.prisma.trip.update({ where: { id }, data: { isActive: false } }); + } +} diff --git a/src/units/units.controller.spec.ts b/src/units/units.controller.spec.ts new file mode 100644 index 0000000..b572bc8 --- /dev/null +++ b/src/units/units.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UnitsController } from './units.controller'; + +describe('UnitsController', () => { + let controller: UnitsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UnitsController], + }).compile(); + + controller = module.get(UnitsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/units/units.controller.ts b/src/units/units.controller.ts new file mode 100644 index 0000000..b92714a --- /dev/null +++ b/src/units/units.controller.ts @@ -0,0 +1,103 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseUUIDPipe, + NotFoundException, + UseGuards, +} from '@nestjs/common'; +import { UnitsService, type CreateIdentifierDto, type UpdateIdentifierDto } from './units.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RbacGuard } from '../auth/rbac.guard'; +import { Roles } from '../auth/rbac.decorator'; +import { Role } from '@prisma/client'; + +@Controller('units') +@UseGuards(JwtAuthGuard, RbacGuard) +export class UnitsController { + constructor(private readonly unitsService: UnitsService) {} + + // Unit CRUD + @Post() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + create() { + return this.unitsService.create(); + } + + @Get() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findAll() { + return this.unitsService.findAll(); + } + + @Get(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findOne(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.unitsService.findOne(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Unit not found'); + } + } + + @Delete(':id') + @Roles(Role.ADMIN) + remove(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.unitsService.remove(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Unit not found'); + } + } + + // Identifier CRUD + @Post(':unitId/identifiers') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + addIdentifier(@Param('unitId', ParseUUIDPipe) unitId: string, @Body() data: CreateIdentifierDto) { + try { + return this.unitsService.addIdentifier(unitId, data); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Unit not found'); + } + } + + @Get(':unitId/identifiers') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findIdentifiers(@Param('unitId', ParseUUIDPipe) unitId: string) { + try { + return this.unitsService.findIdentifiers(unitId); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Unit not found'); + } + } + + @Patch('identifiers/:id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + updateIdentifier(@Param('id', ParseUUIDPipe) id: string, @Body() data: UpdateIdentifierDto) { + try { + return this.unitsService.updateIdentifier(id, data); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Identifier not found'); + } + } + + @Delete('identifiers/:id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + removeIdentifier(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.unitsService.removeIdentifier(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('Identifier not found'); + } + } +} diff --git a/src/units/units.module.ts b/src/units/units.module.ts new file mode 100644 index 0000000..08e3110 --- /dev/null +++ b/src/units/units.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UnitsService } from './units.service'; +import { UnitsController } from './units.controller'; + +@Module({ + providers: [UnitsService], + controllers: [UnitsController], + exports: [UnitsService], +}) +export class UnitsModule {} diff --git a/src/units/units.service.spec.ts b/src/units/units.service.spec.ts new file mode 100644 index 0000000..573b673 --- /dev/null +++ b/src/units/units.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UnitsService } from './units.service'; + +describe('UnitsService', () => { + let service: UnitsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UnitsService], + }).compile(); + + service = module.get(UnitsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/units/units.service.ts b/src/units/units.service.ts new file mode 100644 index 0000000..875bfd1 --- /dev/null +++ b/src/units/units.service.ts @@ -0,0 +1,84 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { Unit, UnitIdentifier } from '@prisma/client'; + +export type CreateUnitDto = {}; + +export type CreateIdentifierDto = { + type: 'SERIAL' | 'INTERNAL' | 'CUSTOMER'; + value: string; + validFrom?: Date; + validTo?: Date; +}; + +export type UpdateIdentifierDto = Partial; + +@Injectable() +export class UnitsService { + constructor(private readonly prisma: PrismaService) {} + + // Unit CRUD + async create(): Promise { + return this.prisma.unit.create({ data: {} }); + } + + async findAll(): Promise { + return this.prisma.unit.findMany(); + } + + async findOne(id: string): Promise { + const unit = await this.prisma.unit.findUnique({ where: { id } }); + if (!unit) throw new NotFoundException('Unit not found'); + return unit; + } + + async remove(id: string): Promise { + const unit = await this.prisma.unit.findUnique({ where: { id } }); + if (!unit) throw new NotFoundException('Unit not found'); + + // Soft delete via cascade on identifiers (or keep unit, just mark identifiers inactive) + // For now, simply delete if no repair orders exist; otherwise throw + const hasOrders = await this.prisma.repairOrder.findFirst({ where: { unitId: id } }); + if (hasOrders) throw new NotFoundException('Cannot delete unit with existing repair orders'); + + await this.prisma.unit.delete({ where: { id } }); + } + + // Identifier CRUD + async addIdentifier(unitId: string, data: CreateIdentifierDto): Promise { + const unit = await this.prisma.unit.findUnique({ where: { id: unitId } }); + if (!unit) throw new NotFoundException('Unit not found'); + + return this.prisma.unitIdentifier.create({ + data: { unitId, ...data }, + }); + } + + async updateIdentifier(id: string, data: UpdateIdentifierDto): Promise { + const identifier = await this.prisma.unitIdentifier.findUnique({ where: { id } }); + if (!identifier) throw new NotFoundException('Identifier not found'); + + return this.prisma.unitIdentifier.update({ where: { id }, data }); + } + + async removeIdentifier(id: string): Promise { + const identifier = await this.prisma.unitIdentifier.findUnique({ where: { id } }); + if (!identifier) throw new NotFoundException('Identifier not found'); + + // Soft delete: set validTo to now + await this.prisma.unitIdentifier.update({ + where: { id }, + data: { validTo: new Date() }, + }); + } + + async findIdentifiers(unitId: string): Promise { + const unit = await this.prisma.unit.findUnique({ where: { id: unitId } }); + if (!unit) throw new NotFoundException('Unit not found'); + + return this.prisma.unitIdentifier.findMany({ + where: { unitId }, + orderBy: { validFrom: 'desc' }, + }); + } +} diff --git a/src/users/users.controller.spec.ts b/src/users/users.controller.spec.ts new file mode 100644 index 0000000..3e27c39 --- /dev/null +++ b/src/users/users.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersController } from './users.controller'; + +describe('UsersController', () => { + let controller: UsersController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + }).compile(); + + controller = module.get(UsersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts new file mode 100644 index 0000000..a39efab --- /dev/null +++ b/src/users/users.controller.ts @@ -0,0 +1,74 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseUUIDPipe, + ConflictException, + NotFoundException, + UseGuards, +} from '@nestjs/common'; +import { UsersService, type CreateUserDto, type UpdateUserDto } from './users.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RbacGuard } from '../auth/rbac.guard'; +import { Roles } from '../auth/rbac.decorator'; +import { Role } from '@prisma/client'; + +@Controller('users') +@UseGuards(JwtAuthGuard, RbacGuard) +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Post() + @Roles(Role.ADMIN) + async create(@Body() createUserDto: CreateUserDto) { + try { + return await this.usersService.create(createUserDto); + } catch (e) { + if (e instanceof ConflictException) throw e; + throw new ConflictException('User already exists'); + } + } + + @Get() + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC) + findAll() { + return this.usersService.findAll(); + } + + @Get(':id') + @Roles(Role.ADMIN, Role.DIRECTOR, Role.MECHANIC, Role.WORKER) + findOne(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.usersService.findOne(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('User not found'); + } + } + + @Patch(':id') + @Roles(Role.ADMIN) + update(@Param('id', ParseUUIDPipe) id: string, @Body() updateUserDto: UpdateUserDto) { + try { + return this.usersService.update(id, updateUserDto); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('User not found'); + } + } + + @Delete(':id') + @Roles(Role.ADMIN) + remove(@Param('id', ParseUUIDPipe) id: string) { + try { + return this.usersService.remove(id); + } catch (e) { + if (e instanceof NotFoundException) throw e; + throw new NotFoundException('User not found'); + } + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts new file mode 100644 index 0000000..58cb025 --- /dev/null +++ b/src/users/users.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { UsersService } from './users.service'; +import { UsersController } from './users.controller'; + +@Module({ + imports: [PassportModule], + providers: [UsersService], + controllers: [UsersController], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts new file mode 100644 index 0000000..62815ba --- /dev/null +++ b/src/users/users.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersService } from './users.service'; + +describe('UsersService', () => { + let service: UsersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsersService], + }).compile(); + + service = module.get(UsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 0000000..cc3eeab --- /dev/null +++ b/src/users/users.service.ts @@ -0,0 +1,78 @@ +import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; +import { User, Role } from '@prisma/client'; + +export type CreateUserDto = { + email: string; + password: string; + firstName: string; + lastName: string; + role?: Role; +}; + +export type UpdateUserDto = Partial> & { + password?: string; + isActive?: boolean; +}; + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateUserDto): Promise> { + const existing = await this.prisma.user.findUnique({ where: { email: data.email } }); + if (existing) throw new ConflictException('User with this email already exists'); + + const hashedPassword = await bcrypt.hash(data.password, 12); + const user = await this.prisma.user.create({ + data: { ...data, password: hashedPassword }, + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + } + + async findAll(): Promise[]> { + const users = await this.prisma.user.findMany({ where: { isActive: true } }); + return users.map(({ password, ...u }) => u); + } + + async findOne(id: string): Promise> { + const user = await this.prisma.user.findUnique({ where: { id, isActive: true } }); + if (!user) throw new NotFoundException('User not found'); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + } + + async findByEmail(email: string): Promise { + return this.prisma.user.findUnique({ where: { email, isActive: true } }); + } + + async update(id: string, data: UpdateUserDto): Promise> { + const existing = await this.prisma.user.findUnique({ where: { id, isActive: true } }); + if (!existing) throw new NotFoundException('User not found'); + + let hashedPassword: string | undefined; + if (data.password) { + hashedPassword = await bcrypt.hash(data.password, 12); + } + + const user = await this.prisma.user.update({ + where: { id }, + data: { ...data, ...(hashedPassword && { password: hashedPassword }) }, + }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...userWithoutPassword } = user; + return userWithoutPassword; + } + + async remove(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id, isActive: true } }); + if (!user) throw new NotFoundException('User not found'); + + // Soft delete + await this.prisma.user.update({ where: { id }, data: { isActive: false } }); + } +} diff --git a/test-api.sh b/test-api.sh new file mode 100755 index 0000000..e1e2daf --- /dev/null +++ b/test-api.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="http://127.0.0.1:3000" +ACCESS_TOKEN="" +REFRESH_TOKEN="" + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +log() { + echo -e "${GREEN}[$(date '+%H:%M:%S')]${NC} $1" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 1. Health check +log "Health check" +curl -fsS "$BASE_URL/health" | jq . + +# 2. Auth: login as admin +log "Login as admin" +LOGIN_RESP=$(curl -fsS -X POST "$BASE_URL/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@crm.local","password":"admin123"}') +ACCESS_TOKEN=$(echo "$LOGIN_RESP" | jq -r .accessToken) +REFRESH_TOKEN=$(echo "$LOGIN_RESP" | jq -r .refreshToken) +if [[ -z "$ACCESS_TOKEN" || "$ACCESS_TOKEN" == "null" ]]; then + error "Login failed" + exit 1 +fi +log "Access token obtained" + +# Helper for authenticated requests +auth_get() { + curl -fsS -H "Authorization: Bearer $ACCESS_TOKEN" "$BASE_URL$1" +} + +auth_post() { + curl -fsS -X POST -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" "$BASE_URL$1" -d "$2" +} + +# 3. Users: list (admin only) +log "List users" +auth_get "/users" | jq . + +# 4. Contractors: list +log "List contractors" +auth_get "/contractors" | jq . + +# 5. Create contractor (admin/director/mechanic) +log "Create contractor" +auth_post "/contractors" '{"name":"Test Contractor","address":"Test address"}' | jq . + +# 6. Units: list +log "List units" +auth_get "/units" | jq . + +# 7. Create unit and identifier +log "Create unit" +UNIT_RESP=$(auth_post "/units" '{}') +UNIT_ID=$(echo "$UNIT_RESP" | jq -r .id) +if [[ -z "$UNIT_ID" || "$UNIT_ID" == "null" ]]; then + error "Unit create failed" + echo "$UNIT_RESP" | jq . + exit 1 +fi +log "Unit created: $UNIT_ID" + +log "Add identifier to unit" +auth_post "/units/$UNIT_ID/identifiers" '{"type":"SERIAL","value":"TESTSN001"}' | jq . + +# 8. RepairOrders: list +log "List repair orders" +auth_get "/repair-orders" | jq . + +# 9. Create repair order (worker+) +log "Create repair order" +FIRST_CONTRACTOR_ID=$(auth_get "/contractors" | jq -r '.[0].id') +if [[ -z "$FIRST_CONTRACTOR_ID" || "$FIRST_CONTRACTOR_ID" == "null" ]]; then + error "No contractors found" + exit 1 +fi +REPAIR_RESP=$(auth_post "/repair-orders" '{"unitId":"'$UNIT_ID'","contractorId":"'$FIRST_CONTRACTOR_ID'","description":"Test repair"}') +REPAIR_ID=$(echo "$REPAIR_RESP" | jq -r .id) +if [[ -z "$REPAIR_ID" || "$REPAIR_ID" == "null" ]]; then + error "Repair order create failed" + echo "$REPAIR_RESP" | jq . + exit 1 +fi +log "Repair order created: $REPAIR_ID" + +# 10. Trips: list +log "List trips" +auth_get "/trips" | jq . + +# 11. Create trip (worker+) +log "Create trip" +auth_post "/trips" '{"contractorId":"'$FIRST_CONTRACTOR_ID'","scheduledFor":"'$(date -d '+1 day' -Iseconds)'","description":"Test trip"}' | jq . + +# 12. Calculations: list +log "List calculations" +auth_get "/calculations" | jq . + +# 13. Create calculation (mechanic+) +log "Create calculation" +auth_post "/calculations" '{"repairOrderId":"'$REPAIR_ID'"}' | jq . + +# 14. Refresh token +log "Refresh token" +REFRESH_RESP=$(curl -fsS -X POST "$BASE_URL/auth/refresh" \ + -H "Content-Type: application/json" \ + -d "{\"refreshToken\":\"$REFRESH_TOKEN\"}") +NEW_ACCESS=$(echo "$REFRESH_RESP" | jq -r .accessToken) +if [[ -z "$NEW_ACCESS" || "$NEW_ACCESS" == "null" ]]; then + error "Refresh failed" + exit 1 +fi +ACCESS_TOKEN="$NEW_ACCESS" +log "Token refreshed" + +log "All tests passed" diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6..89e8968 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,9 @@ { "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "incremental": false + }, + "include": ["src/**/*.ts"], "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] }