feat: domain CRUD modules + auth/jwt/rbac + seeds + infra fixes

This commit is contained in:
gieacrm 2026-01-22 12:20:17 +01:00
parent db9a580f19
commit bc3e929759
52 changed files with 2914 additions and 41 deletions

5
.gitignore vendored
View File

@ -2,6 +2,11 @@ node_modules/
/dist/
/coverage/
*.tsbuildinfo
prisma/*.js
prisma/*.js.map
prisma/*.d.ts
.env
.env.*

304
package-lock.json generated
View File

@ -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",

View File

@ -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"

View File

@ -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;

View File

@ -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])
}

271
prisma/seed.ts Normal file
View File

@ -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);
});

View File

@ -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 {}

View File

@ -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>(AuthController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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<AuthResponse> {
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<AuthResponse> {
try {
return await this.authService.refresh(refreshToken);
} catch (e) {
if (e instanceof UnauthorizedException) throw e;
throw new UnauthorizedException('Refresh failed');
}
}
}

22
src/auth/auth.module.ts Normal file
View File

@ -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 {}

View File

@ -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>(AuthService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

65
src/auth/auth.service.ts Normal file
View File

@ -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<User, 'password'>;
accessToken: string;
refreshToken: string;
};
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async validateUser(email: string, pass: string): Promise<Omit<User, 'password'> | 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<AuthResponse> {
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<AuthResponse> {
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');
}
}
}

View File

@ -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 };
},
);

View File

@ -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<boolean>(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;
}
}

19
src/auth/jwt.strategy.ts Normal file
View File

@ -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 };
}
}

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@ -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);

32
src/auth/rbac.guard.ts Normal file
View File

@ -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<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(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);
}
}

View File

@ -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>(CalculationsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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');
}
}
}

View File

@ -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 {}

View File

@ -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>(CalculationsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -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<CreateCalculationDto> & {
isActive?: boolean;
};
export type CreateTemplateDto = {
contractorId?: string;
name: string;
};
export type UpdateTemplateDto = Partial<CreateTemplateDto> & {
isActive?: boolean;
};
export type CreateItemDto = {
calculationId: string;
type: 'WORK' | 'MATERIAL';
name: string;
quantity: number;
unit: string;
price?: number;
};
export type UpdateItemDto = Partial<CreateItemDto> & {
isActive?: boolean;
};
@Injectable()
export class CalculationsService {
constructor(private readonly prisma: PrismaService) {}
// Template CRUD
async createTemplate(data: CreateTemplateDto): Promise<CalculationTemplate> {
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<CalculationTemplate[]> {
return this.prisma.calculationTemplate.findMany({ where: { isActive: true } });
}
async findTemplate(id: string): Promise<CalculationTemplate> {
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<CalculationTemplate> {
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<void> {
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<Calculation> {
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<Calculation[]> {
return this.prisma.calculation.findMany({
where: { isActive: true },
include: { repairOrder: true, template: true, items: true },
orderBy: { createdAt: 'desc' },
});
}
async findOne(id: string): Promise<Calculation> {
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<Calculation> {
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<void> {
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<CalculationItem> {
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<CalculationItem> {
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<void> {
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 } });
}
}

View File

@ -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>(ContractorsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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');
}
}
}

View File

@ -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 {}

View File

@ -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>(ContractorsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -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<CreateContractorDto> & {
isActive?: boolean;
};
@Injectable()
export class ContractorsService {
constructor(private readonly prisma: PrismaService) {}
async create(data: CreateContractorDto): Promise<Contractor> {
const contractor = await this.prisma.contractor.create({ data });
return contractor;
}
async findAll(): Promise<Contractor[]> {
return this.prisma.contractor.findMany({ where: { isActive: true } });
}
async findOne(id: string): Promise<Contractor> {
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<Contractor> {
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<void> {
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 } });
}
}

View File

@ -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`);
}

View File

@ -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);
}

View File

@ -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>(RepairOrdersController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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');
}
}
}

View File

@ -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 {}

View File

@ -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>(RepairOrdersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -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<CreateRepairOrderDto, 'unitId' | 'contractorId'> & {
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<RepairOrder> {
// 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<RepairOrder[]> {
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<RepairOrder> {
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<RepairOrder> {
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<void> {
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<void> {
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<void> {
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 } });
}
}

View File

@ -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>(TripsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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');
}
}
}

10
src/trips/trips.module.ts Normal file
View File

@ -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 {}

View File

@ -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>(TripsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

108
src/trips/trips.service.ts Normal file
View File

@ -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<CreateTripDto, 'scheduledFor'> & {
scheduledFor?: Date;
completedAt?: Date;
isActive?: boolean;
}
>;
export type CreateServiceObjectDto = {
contractorId: string;
name: string;
address?: string;
};
export type UpdateServiceObjectDto = Partial<CreateServiceObjectDto> & {
isActive?: boolean;
};
@Injectable()
export class TripsService {
constructor(private readonly prisma: PrismaService) {}
// ServiceObject CRUD
async createServiceObject(data: CreateServiceObjectDto): Promise<ServiceObject> {
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<ServiceObject[]> {
return this.prisma.serviceObject.findMany({ where: { isActive: true } });
}
async findServiceObject(id: string): Promise<ServiceObject> {
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<ServiceObject> {
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<void> {
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<Trip> {
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<Trip[]> {
return this.prisma.trip.findMany({
where: { isActive: true },
include: { contractor: true, serviceObject: true },
orderBy: { scheduledFor: 'asc' },
});
}
async findOne(id: string): Promise<Trip> {
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<Trip> {
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<void> {
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 } });
}
}

View File

@ -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>(UnitsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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');
}
}
}

10
src/units/units.module.ts Normal file
View File

@ -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 {}

View File

@ -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>(UnitsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -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<CreateIdentifierDto>;
@Injectable()
export class UnitsService {
constructor(private readonly prisma: PrismaService) {}
// Unit CRUD
async create(): Promise<Unit> {
return this.prisma.unit.create({ data: {} });
}
async findAll(): Promise<Unit[]> {
return this.prisma.unit.findMany();
}
async findOne(id: string): Promise<Unit> {
const unit = await this.prisma.unit.findUnique({ where: { id } });
if (!unit) throw new NotFoundException('Unit not found');
return unit;
}
async remove(id: string): Promise<void> {
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<UnitIdentifier> {
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<UnitIdentifier> {
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<void> {
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<UnitIdentifier[]> {
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' },
});
}
}

View File

@ -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>(UsersController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@ -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');
}
}
}

12
src/users/users.module.ts Normal file
View File

@ -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 {}

View File

@ -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>(UsersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -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<Omit<CreateUserDto, 'password'>> & {
password?: string;
isActive?: boolean;
};
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async create(data: CreateUserDto): Promise<Omit<User, 'password'>> {
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<Omit<User, 'password'>[]> {
const users = await this.prisma.user.findMany({ where: { isActive: true } });
return users.map(({ password, ...u }) => u);
}
async findOne(id: string): Promise<Omit<User, 'password'>> {
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<User | null> {
return this.prisma.user.findUnique({ where: { email, isActive: true } });
}
async update(id: string, data: UpdateUserDto): Promise<Omit<User, 'password'>> {
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<void> {
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 } });
}
}

126
test-api.sh Executable file
View File

@ -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"

View File

@ -1,4 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"incremental": false
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}