From 71c8a90f4ae83dabfb525499a2cd90fb45742333 Mon Sep 17 00:00:00 2001 From: KuchiMercy Date: Sat, 24 Jan 2026 18:55:12 +0100 Subject: [PATCH] Create Carrier Management Module --- backend/package-lock.json | 295 +++++++----------- backend/src/app.module.ts | 2 + backend/src/carriers/README.md | 134 ++++++++ backend/src/carriers/carrier.module.ts | 17 + backend/src/carriers/carrier.service.spec.ts | 112 +++++++ .../controllers/carrier.controller.ts | 210 +++++++++++++ .../src/carriers/dto/create-carrier.dto.ts | 24 ++ backend/src/carriers/dto/create-rating.dto.ts | 22 ++ .../src/carriers/dto/create-vehicle.dto.ts | 39 +++ .../src/carriers/dto/update-carrier.dto.ts | 13 + .../src/carriers/dto/update-vehicle.dto.ts | 9 + .../entities/carrier-rating.entity.ts | 43 +++ .../src/carriers/entities/carrier.entity.ts | 63 ++++ .../src/carriers/entities/vehicle.entity.ts | 79 +++++ .../src/carriers/services/carrier.service.ts | 259 +++++++++++++++ backend/test/carrier.e2e-spec.ts | 66 ++++ 16 files changed, 1199 insertions(+), 188 deletions(-) create mode 100644 backend/src/carriers/README.md create mode 100644 backend/src/carriers/carrier.module.ts create mode 100644 backend/src/carriers/carrier.service.spec.ts create mode 100644 backend/src/carriers/controllers/carrier.controller.ts create mode 100644 backend/src/carriers/dto/create-carrier.dto.ts create mode 100644 backend/src/carriers/dto/create-rating.dto.ts create mode 100644 backend/src/carriers/dto/create-vehicle.dto.ts create mode 100644 backend/src/carriers/dto/update-carrier.dto.ts create mode 100644 backend/src/carriers/dto/update-vehicle.dto.ts create mode 100644 backend/src/carriers/entities/carrier-rating.entity.ts create mode 100644 backend/src/carriers/entities/carrier.entity.ts create mode 100644 backend/src/carriers/entities/vehicle.entity.ts create mode 100644 backend/src/carriers/services/carrier.service.ts create mode 100644 backend/test/carrier.e2e-spec.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 5c660ab6..97cd61da 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -990,7 +990,6 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -3330,7 +3329,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.0.tgz", "integrity": "sha512-1cB+Jyltu/uUPNQrpUimRHEQHrnQrpLzVj6dU3dgn6iDDDdahr10TgHFGTmw5VuJ9GzKZsCLDL78VSwJAs/9JQ==", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "axios": "^1.3.1", @@ -3388,7 +3386,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.6.tgz", "integrity": "sha512-krKwLLcFmeuKDqngG2N/RuZHCs2ycsKcxWIDgcm7i1lf3sQ0iG03ci+DsP/r3FcT/eJDFsIHnKtNta2LIi7PzQ==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.0.0", "iterare": "1.2.1", @@ -3467,7 +3464,6 @@ "integrity": "sha512-siWX7UDgErisW18VTeJA+x+/tpNZrJewjTBsRPF3JVxuWRuAB1kRoiJcxHgln8Lb5UY9NdvklITR84DUEXD0Cg==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3517,15 +3513,16 @@ } }, "node_modules/@nestjs/jwt": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.0.tgz", - "integrity": "sha512-v7YRsW3Xi8HNTsO+jeHSEEqelX37TVWgwt+BcxtkG/OfXJEOs6GZdbdza200d6KqId1pJQZ6UPj1F0M6E+mxaA==", + "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.7", - "jsonwebtoken": "9.0.2" + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" }, - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + "engines": { + "node": ">=18.0.0" } }, "node_modules/@nestjs/mapped-types": { @@ -3552,6 +3549,7 @@ "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" @@ -3562,7 +3560,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.0.10.tgz", "integrity": "sha512-UVSf0yaWFBC2Zn2FOWABXxCnyG8XNIXrNnvTFpbUFqaJu1YDdwJ7wQBBqxq9CtJT7ILqSmfhOU7HS0d/0EAxpw==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.0.1", @@ -3579,21 +3576,37 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-socket.io": { + "version": "11.1.12", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz", + "integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.3", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.0.1", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.1.tgz", "integrity": "sha512-PHPAUk4sXkfCxiMacD1JFC+vEyzXjZJRCu1KT2MmG2hrTiMDMk5KtMprro148JUefNuWbVyN0uLTJVSmWVzhoA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "@angular-devkit/core": "19.1.7", - "@angular-devkit/schematics": "19.1.7", - "comment-json": "4.2.5", - "jsonc-parser": "3.3.1", - "pluralize": "8.0.0" + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, - "peerDependencies": { - "typescript": ">=4.8.2" + "engines": { + "node": ">=18.0.0" } }, "node_modules/@nestjs/swagger": { @@ -3740,7 +3753,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-11.0.0.tgz", "integrity": "sha512-SOeUQl70Lb2OfhGkvnh4KXWlsd+zA08RuuQgT7kKbzivngxzSo1Oc7Usu5VxCxACQC9wc2l9esOHILSJeK7rJA==", "license": "MIT", - "peer": true, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -3749,16 +3761,40 @@ "typeorm": "^0.3.0" } }, + "node_modules/@nestjs/websockets": { + "version": "11.1.12", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz", + "integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "dev": true, - "engines": { - "node": "^14.21.3 || >=16" + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" }, - "funding": { - "url": "https://paulmillr.com/funding/" + "engines": { + "node": ">=18.0.0" } }, "node_modules/@nodelib/fs.scandir": { @@ -4131,14 +4167,16 @@ "node": ">=18.0.0" } }, - "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", + "node_modules/@smithy/middleware-serde": { + "version": "4.2.9", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.9.tgz", + "integrity": "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@types/jsonwebtoken": "9.0.10", - "jsonwebtoken": "9.0.3" + "@smithy/protocol-http": "^5.3.8", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" @@ -4158,14 +4196,20 @@ "node": ">=18.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/@smithy/node-config-provider": { + "version": "4.3.8", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.8.tgz", + "integrity": "sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.8", + "@smithy/shared-ini-file-loader": "^4.4.3", + "@smithy/types": "^4.12.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, "node_modules/@smithy/node-http-handler": { @@ -4185,29 +4229,10 @@ "node": ">=18.0.0" } }, - "node_modules/@nestjs/platform-socket.io": { - "version": "11.1.12", - "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.12.tgz", - "integrity": "sha512-1itTTYsAZecrq2NbJOkch32y8buLwN7UpcNRdJrhlS+ovJ5GxLx3RyJ3KylwBhbYnO5AeYyL1U/i4W5mg/4qDA==", - "license": "MIT", - "dependencies": { - "socket.io": "4.8.3", - "tslib": "2.8.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "@nestjs/common": "^11.0.0", - "@nestjs/websockets": "^11.0.0", - "rxjs": "^7.1.0" - } - }, - "node_modules/@nestjs/schematics": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.1.tgz", - "integrity": "sha512-PHPAUk4sXkfCxiMacD1JFC+vEyzXjZJRCu1KT2MmG2hrTiMDMk5KtMprro148JUefNuWbVyN0uLTJVSmWVzhoA==", + "node_modules/@smithy/property-provider": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.8.tgz", + "integrity": "sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4327,33 +4352,10 @@ "node": ">=18.0.0" } }, - "node_modules/@nestjs/websockets": { - "version": "11.1.12", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.12.tgz", - "integrity": "sha512-ulSOYcgosx1TqY425cRC5oXtAu1R10+OSmVfgyR9ueR25k4luekURt8dzAZxhxSCI0OsDj9WKCFLTkEuAwg0wg==", - "license": "MIT", - "dependencies": { - "iterare": "1.2.1", - "object-hash": "3.0.0", - "tslib": "2.8.1" - }, - "peerDependencies": { - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "@nestjs/platform-socket.io": "^11.0.0", - "reflect-metadata": "^0.1.12 || ^0.2.0", - "rxjs": "^7.1.0" - }, - "peerDependenciesMeta": { - "@nestjs/platform-socket.io": { - "optional": true - } - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "node_modules/@smithy/types": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz", + "integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -4677,7 +4679,6 @@ "integrity": "sha512-Q5FsI3Cw0fGMXhmsg7c08i4EmXCrcl+WnAxb6LYOLHw4JFFC3yzmx9LaXZ7QMbA+JZXbigU2TirI7RAfO0Qlnw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@xhmikosr/bin-wrapper": "^13.0.5", @@ -4746,10 +4747,9 @@ "version": "1.10.18", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.18.tgz", "integrity": "sha512-IUWKD6uQYGRy8w2X9EZrtYg1O3SCijlHbCXzMaHQYc1X7yjijQh4H3IVL9ssZZyVp2ZDfQZu4bD5DWxxvpyjvg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.17" @@ -4789,7 +4789,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4806,7 +4805,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4823,7 +4821,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4840,7 +4837,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4857,7 +4853,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4874,7 +4869,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4891,7 +4885,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4908,7 +4901,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4925,7 +4917,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4942,7 +4933,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4956,14 +4946,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.17", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz", "integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -5136,7 +5126,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5315,7 +5304,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -5504,7 +5492,6 @@ "integrity": "sha512-Tqoa05bu+t5s8CTZFaGpCH2ub3QeT9YDkXbPd3uQ4SfsLoh1/vv2GEYAioPoxCWJJNsenXlC88tRjwoHNts1oQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.24.1", "@typescript-eslint/types": "8.24.1", @@ -6043,7 +6030,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6091,7 +6077,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6367,13 +6352,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", - "dev": true, - "license": "MIT" - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -6428,7 +6406,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -6658,6 +6635,16 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base32.js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", @@ -6966,7 +6953,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", @@ -7383,15 +7369,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7623,23 +7607,6 @@ "node": ">= 6" } }, - "node_modules/comment-json": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", - "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", - "esprima": "^4.0.1", - "has-own-prop": "^2.0.0", - "repeat-string": "^1.6.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -8572,7 +8539,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8634,7 +8600,6 @@ "integrity": "sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "build/bin/cli.js" }, @@ -10021,16 +9986,6 @@ "node": ">=8" } }, - "node_modules/has-own-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", - "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", @@ -10732,7 +10687,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -13069,7 +13023,6 @@ "version": "6.10.1", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -13492,7 +13445,6 @@ "version": "0.7.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -13656,7 +13608,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -13763,7 +13714,6 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-9.6.0.tgz", "integrity": "sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==", "license": "MIT", - "peer": true, "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", @@ -13795,7 +13745,6 @@ "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.4.0.tgz", "integrity": "sha512-vjQsKBE+VN1LVchjbfLE7B6nBeGASZNRNKsR68VS0DolTm5R3zo+47JX1wjm0O96dcbvA7vnqt8YqOWlG5nN0w==", "license": "MIT", - "peer": true, "dependencies": { "get-caller-file": "^2.0.5", "pino": "^9.0.0", @@ -13898,16 +13847,6 @@ "node": ">=8" } }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -13968,7 +13907,6 @@ "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -14082,7 +14020,6 @@ "version": "15.1.3", "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", - "peer": true, "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" @@ -14488,8 +14425,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/regenerator-runtime": { "version": "0.14.1", @@ -14506,16 +14442,6 @@ "node": ">= 0.10" } }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, "node_modules/require-addon": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/require-addon/-/require-addon-1.1.0.tgz", @@ -14879,7 +14805,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -15986,7 +15911,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -16361,7 +16285,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -16528,7 +16451,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.27.tgz", "integrity": "sha512-pNV1bn+1n8qEe8tUNsNdD8ejuPcMAg47u2lUGnbsajiNUr3p2Js1XLKQjBMH0yMRMDfdX8T+fIRejFmIwy9x4A==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^3.17.0", @@ -16739,7 +16661,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17160,7 +17081,6 @@ "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -17228,7 +17148,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 92d284ab..b88a99a7 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { WebSocketModule } from './websocket/websocket.module'; import { NotificationModule } from './notifications/notifications.module'; import { FreightJobsModule } from './freight-jobs/freight-jobs.module'; +import { CarrierModule } from './carriers/carrier.module'; @Module({ @@ -30,6 +31,7 @@ import { FreightJobsModule } from './freight-jobs/freight-jobs.module'; WebSocketModule, NotificationModule, FreightJobsModule, + CarrierModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/carriers/README.md b/backend/src/carriers/README.md new file mode 100644 index 00000000..d4f2d29c --- /dev/null +++ b/backend/src/carriers/README.md @@ -0,0 +1,134 @@ +# Carrier Management Module + +## Overview +The Carrier Management Module handles carrier profiles, vehicle fleets, driver information, and carrier performance metrics. + +## Features +- Carrier profile management (CRUD operations) +- Fleet management (vehicle CRUD operations) +- Driver assignment tracking +- Rating and performance calculation +- Service area management +- Verification workflow + +## Entities + +### Carrier +- `id` (UUID) - Unique identifier +- `userId` (UUID) - Associated user ID +- `companyName` (string) - Carrier company name +- `licenseNumber` (string) - Unique license number +- `insurancePolicy` (string) - Insurance policy information +- `serviceAreas` (JSONB array) - Geographic service areas +- `averageRating` (decimal) - Average rating score +- `totalDeliveries` (integer) - Total completed deliveries +- `onTimePercentage` (decimal) - Percentage of on-time deliveries +- `isVerified` (boolean) - Verification status +- `isActive` (boolean) - Active status + +### Vehicle +- `id` (UUID) - Unique identifier +- `carrierId` (UUID) - Associated carrier ID +- `vehicleType` (enum) - Type of vehicle (truck, van, cargo_ship, car, motorcycle, trailer) +- `licensePlate` (string) - License plate number +- `capacityWeight` (decimal) - Maximum weight capacity +- `capacityVolume` (decimal) - Maximum volume capacity +- `year` (integer) - Manufacturing year +- `make` (string) - Vehicle make +- `model` (string) - Vehicle model +- `isActive` (boolean) - Active status + +### Carrier Rating +- `id` (UUID) - Unique identifier +- `carrierId` (UUID) - Associated carrier ID +- `ratedBy` (UUID) - ID of the rater +- `freightJobId` (UUID) - Associated freight job (optional) +- `rating` (integer) - Rating score (1-5) +- `review` (text) - Review text (optional) + +## API Endpoints + +### Carriers +- `POST /api/v1/carriers` - Create a new carrier +- `GET /api/v1/carriers` - Get all carriers (with optional filters) +- `GET /api/v1/carriers/:id` - Get a specific carrier +- `PATCH /api/v1/carriers/:id` - Update a carrier +- `DELETE /api/v1/carriers/:id` - Delete a carrier + +### Vehicles +- `POST /api/v1/carriers/:id/vehicles` - Add a vehicle to a carrier +- `GET /api/v1/carriers/:id/vehicles` - Get all vehicles for a carrier +- `PATCH /api/v1/carriers/vehicles/:vehicleId` - Update a vehicle +- `DELETE /api/v1/carriers/vehicles/:vehicleId` - Delete a vehicle + +### Performance & Ratings +- `GET /api/v1/carriers/:id/performance` - Get carrier performance metrics +- `POST /api/v1/carriers/:id/rate` - Rate a carrier + +## Business Logic + +### Average Rating Calculation +- The average rating is automatically recalculated after each new rating +- Formula: Sum of all ratings / Total number of ratings + +### On-Time Percentage Updates +- On-time percentage is updated after job completion +- Formula: (Total on-time deliveries / Total deliveries) * 100 + +### Verification Requirements +- Only verified carriers can accept freight jobs +- Carriers must have at least one active vehicle + +### Search and Filtering +- Filter by service area +- Filter by minimum rating +- Filter by verification status +- Search by company name or license number + +## Authorization +- Carriers can only edit their own profiles +- Admins have full access to all carrier data + +## Usage Examples + +### Create a Carrier +```bash +curl -X POST http://localhost:3000/carriers \ + -H "Content-Type: application/json" \ + -d '{ + "userId": "user-id-123", + "companyName": "ABC Logistics", + "licenseNumber": "LIC123456", + "insurancePolicy": "POL123", + "serviceAreas": ["New York", "New Jersey"] + }' +``` + +### Add a Vehicle to a Carrier +```bash +curl -X POST http://localhost:3000/carriers/123/vehicles \ + -H "Content-Type: application/json" \ + -d '{ + "vehicleType": "truck", + "licensePlate": "ABC123", + "capacityWeight": 10000, + "capacityVolume": 500 + }' +``` + +### Rate a Carrier +```bash +curl -X POST http://localhost:3000/carriers/123/rate \ + -H "Content-Type: application/json" \ + -d '{ + "ratedBy": "customer-id-456", + "freightJobId": "job-id-789", + "rating": 5, + "review": "Excellent service!" + }' +``` + +## Error Handling +- `404 Not Found` - Resource does not exist +- `409 Conflict` - Duplicate license number or rating already exists +- `400 Bad Request` - Cannot delete carrier with active vehicles \ No newline at end of file diff --git a/backend/src/carriers/carrier.module.ts b/backend/src/carriers/carrier.module.ts new file mode 100644 index 00000000..4c9d5ab6 --- /dev/null +++ b/backend/src/carriers/carrier.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Carrier } from './entities/carrier.entity'; +import { Vehicle } from './entities/vehicle.entity'; +import { CarrierRating } from './entities/carrier-rating.entity'; +import { CarrierService } from './services/carrier.service'; +import { CarrierController } from './controllers/carrier.controller'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Carrier, Vehicle, CarrierRating]), + ], + controllers: [CarrierController], + providers: [CarrierService], + exports: [CarrierService], +}) +export class CarrierModule {} \ No newline at end of file diff --git a/backend/src/carriers/carrier.service.spec.ts b/backend/src/carriers/carrier.service.spec.ts new file mode 100644 index 00000000..20abd8d1 --- /dev/null +++ b/backend/src/carriers/carrier.service.spec.ts @@ -0,0 +1,112 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CarrierService } from './services/carrier.service'; +import { Carrier } from './entities/carrier.entity'; +import { Vehicle } from './entities/vehicle.entity'; +import { CarrierRating } from './entities/carrier-rating.entity'; +import { CreateCarrierDto } from './dto/create-carrier.dto'; +import { ConflictException, NotFoundException } from '@nestjs/common'; + +describe('CarrierService', () => { + let service: CarrierService; + let carrierRepository: Repository; + let vehicleRepository: Repository; + let ratingRepository: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CarrierService, + { + provide: getRepositoryToken(Carrier), + useClass: Repository, + }, + { + provide: getRepositoryToken(Vehicle), + useClass: Repository, + }, + { + provide: getRepositoryToken(CarrierRating), + useClass: Repository, + }, + ], + }).compile(); + + service = module.get(CarrierService); + carrierRepository = module.get>(getRepositoryToken(Carrier)); + vehicleRepository = module.get>(getRepositoryToken(Vehicle)); + ratingRepository = module.get>(getRepositoryToken(CarrierRating)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new carrier', async () => { + const createCarrierDto: CreateCarrierDto = { + userId: 'user-id', + companyName: 'Test Carrier', + licenseNumber: 'TEST123', + insurancePolicy: 'policy-123', + serviceAreas: ['area1', 'area2'], + }; + + jest.spyOn(carrierRepository, 'findOne').mockResolvedValue(null); + jest.spyOn(carrierRepository, 'create').mockReturnValue(new Carrier()); + jest.spyOn(carrierRepository, 'save').mockResolvedValue({ + id: 'carrier-id', + ...createCarrierDto, + serviceAreas: createCarrierDto.serviceAreas, + averageRating: 0, + totalDeliveries: 0, + onTimePercentage: 0, + isVerified: false, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + vehicles: [], + ratings: [], + } as Carrier); + + const result = await service.create(createCarrierDto); + + expect(result).toBeDefined(); + expect(result.companyName).toBe('Test Carrier'); + }); + + it('should throw ConflictException if license number already exists', async () => { + const createCarrierDto: CreateCarrierDto = { + userId: 'user-id', + companyName: 'Test Carrier', + licenseNumber: 'TEST123', + insurancePolicy: 'policy-123', + }; + + jest.spyOn(carrierRepository, 'findOne').mockResolvedValue(new Carrier()); + + await expect(service.create(createCarrierDto)).rejects.toThrow(ConflictException); + }); + }); + + describe('findOne', () => { + it('should return a carrier if found', async () => { + const carrier = new Carrier(); + carrier.id = 'carrier-id'; + carrier.companyName = 'Test Carrier'; + + jest.spyOn(carrierRepository, 'findOne').mockResolvedValue(carrier); + + const result = await service.findOne('carrier-id'); + + expect(result).toEqual(carrier); + }); + + it('should throw NotFoundException if carrier not found', async () => { + jest.spyOn(carrierRepository, 'findOne').mockResolvedValue(null); + + await expect(service.findOne('carrier-id')).rejects.toThrow(NotFoundException); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/carriers/controllers/carrier.controller.ts b/backend/src/carriers/controllers/carrier.controller.ts new file mode 100644 index 00000000..9fd72dea --- /dev/null +++ b/backend/src/carriers/controllers/carrier.controller.ts @@ -0,0 +1,210 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, + HttpStatus, +} from '@nestjs/common'; +import { CarrierService } from '../services/carrier.service'; +import { CreateCarrierDto } from '../dto/create-carrier.dto'; +import { UpdateCarrierDto } from '../dto/update-carrier.dto'; +import { CreateVehicleDto } from '../dto/create-vehicle.dto'; +import { UpdateVehicleDto } from '../dto/update-vehicle.dto'; +import { CreateRatingDto } from '../dto/create-rating.dto'; +import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger'; + +@ApiTags('carriers') +@Controller('carriers') +export class CarrierController { + constructor(private readonly carrierService: CarrierService) {} + + @Post() + @ApiOperation({ summary: 'Create a new carrier' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Carrier has been successfully created.', + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: 'License number already exists.', + }) + async create(@Body() createCarrierDto: CreateCarrierDto) { + return await this.carrierService.create(createCarrierDto); + } + + @Get() + @ApiOperation({ summary: 'Get all carriers' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return all carriers.', + }) + async findAll( + @Query('serviceArea') serviceArea?: string, + @Query('minRating') minRating?: number, + @Query('isVerified') isVerified?: boolean, + @Query('searchTerm') searchTerm?: string, + ) { + const filters = { + serviceArea, + minRating: minRating ? Number(minRating) : undefined, + isVerified: isVerified !== undefined ? Boolean(isVerified) : undefined, + searchTerm, + }; + + return await this.carrierService.searchCarriers(filters); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a carrier by ID' }) + @ApiParam({ name: 'id', description: 'Carrier ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return the carrier.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Carrier not found.', + }) + async findOne(@Param('id') id: string) { + return await this.carrierService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a carrier' }) + @ApiParam({ name: 'id', description: 'Carrier ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Carrier has been successfully updated.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Carrier not found.', + }) + async update(@Param('id') id: string, @Body() updateCarrierDto: UpdateCarrierDto) { + return await this.carrierService.update(id, updateCarrierDto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a carrier' }) + @ApiParam({ name: 'id', description: 'Carrier ID' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Carrier has been successfully deleted.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Carrier not found.', + }) + @ApiResponse({ + status: HttpStatus.BAD_REQUEST, + description: 'Cannot delete carrier with active vehicles.', + }) + async remove(@Param('id') id: string) { + await this.carrierService.remove(id); + return { message: 'Carrier deleted successfully' }; + } + + // Vehicle routes + @Post(':id/vehicles') + @ApiOperation({ summary: 'Add a vehicle to a carrier' }) + @ApiParam({ name: 'id', description: 'Carrier ID' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Vehicle has been successfully added to the carrier.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Carrier not found.', + }) + async addVehicle( + @Param('id') carrierId: string, + @Body() createVehicleDto: CreateVehicleDto, + ) { + return await this.carrierService.createVehicle(carrierId, createVehicleDto); + } + + @Get(':id/vehicles') + @ApiOperation({ summary: 'Get all vehicles for a carrier' }) + @ApiParam({ name: 'id', description: 'Carrier ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return all vehicles for the carrier.', + }) + async getVehicles(@Param('id') carrierId: string) { + return await this.carrierService.getVehiclesByCarrier(carrierId); + } + + @Patch('vehicles/:vehicleId') + @ApiOperation({ summary: 'Update a vehicle' }) + @ApiParam({ name: 'vehicleId', description: 'Vehicle ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Vehicle has been successfully updated.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Vehicle not found.', + }) + async updateVehicle( + @Param('vehicleId') vehicleId: string, + @Body() updateVehicleDto: UpdateVehicleDto, + ) { + return await this.carrierService.updateVehicle(vehicleId, updateVehicleDto); + } + + @Delete('vehicles/:vehicleId') + @ApiOperation({ summary: 'Delete a vehicle' }) + @ApiParam({ name: 'vehicleId', description: 'Vehicle ID' }) + @ApiResponse({ + status: HttpStatus.NO_CONTENT, + description: 'Vehicle has been successfully removed.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Vehicle not found.', + }) + async deleteVehicle(@Param('vehicleId') vehicleId: string) { + await this.carrierService.deleteVehicle(vehicleId); + return { message: 'Vehicle deleted successfully' }; + } + + // Performance and rating routes + @Get(':id/performance') + @ApiOperation({ summary: 'Get carrier performance metrics' }) + @ApiParam({ name: 'id', description: 'Carrier ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Return carrier performance metrics.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Carrier not found.', + }) + async getPerformance(@Param('id') id: string) { + return await this.carrierService.getPerformanceMetrics(id); + } + + @Post(':id/rate') + @ApiOperation({ summary: 'Rate a carrier' }) + @ApiParam({ name: 'id', description: 'Carrier ID' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Carrier has been successfully rated.', + }) + @ApiResponse({ + status: HttpStatus.CONFLICT, + description: 'Rating already exists for this job.', + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Carrier not found.', + }) + async rateCarrier(@Param('id') carrierId: string, @Body() createRatingDto: CreateRatingDto) { + return await this.carrierService.rateCarrier({ ...createRatingDto, carrierId }); + } +} \ No newline at end of file diff --git a/backend/src/carriers/dto/create-carrier.dto.ts b/backend/src/carriers/dto/create-carrier.dto.ts new file mode 100644 index 00000000..157bd293 --- /dev/null +++ b/backend/src/carriers/dto/create-carrier.dto.ts @@ -0,0 +1,24 @@ +import { IsString, IsEmail, IsArray, IsOptional, IsBoolean, IsNumber, Min, Max, IsEnum } from 'class-validator'; + +export class CreateCarrierDto { + @IsString() + userId: string; + + @IsString() + companyName: string; + + @IsString() + licenseNumber: string; + + @IsString() + @IsOptional() + insurancePolicy?: string; + + @IsArray() + @IsOptional() + serviceAreas?: string[]; + + @IsBoolean() + @IsOptional() + isVerified?: boolean; +} \ No newline at end of file diff --git a/backend/src/carriers/dto/create-rating.dto.ts b/backend/src/carriers/dto/create-rating.dto.ts new file mode 100644 index 00000000..c64b02d9 --- /dev/null +++ b/backend/src/carriers/dto/create-rating.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsUUID, IsNumber, Min, Max, IsOptional } from 'class-validator'; + +export class CreateRatingDto { + @IsString() + carrierId: string; + + @IsString() + ratedBy: string; + + @IsString() + @IsOptional() + freightJobId?: string; + + @IsNumber() + @Min(1) + @Max(5) + rating: number; + + @IsString() + @IsOptional() + review?: string; +} \ No newline at end of file diff --git a/backend/src/carriers/dto/create-vehicle.dto.ts b/backend/src/carriers/dto/create-vehicle.dto.ts new file mode 100644 index 00000000..39b40217 --- /dev/null +++ b/backend/src/carriers/dto/create-vehicle.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsUUID, IsEnum, IsNumber, IsOptional, IsBoolean, Min, Max } from 'class-validator'; +import { VehicleType } from '../entities/vehicle.entity'; + +export class CreateVehicleDto { + @IsString() + carrierId: string; + + @IsEnum(VehicleType) + vehicleType: VehicleType; + + @IsString() + licensePlate: string; + + @IsNumber() + @IsOptional() + capacityWeight?: number; + + @IsNumber() + @IsOptional() + capacityVolume?: number; + + @IsNumber() + @Min(1900) + @Max(2030) + @IsOptional() + year?: number; + + @IsString() + @IsOptional() + make?: string; + + @IsString() + @IsOptional() + model?: string; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} \ No newline at end of file diff --git a/backend/src/carriers/dto/update-carrier.dto.ts b/backend/src/carriers/dto/update-carrier.dto.ts new file mode 100644 index 00000000..52f46855 --- /dev/null +++ b/backend/src/carriers/dto/update-carrier.dto.ts @@ -0,0 +1,13 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateCarrierDto } from './create-carrier.dto'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateCarrierDto extends PartialType(CreateCarrierDto) { + @IsBoolean() + @IsOptional() + isVerified?: boolean; + + @IsBoolean() + @IsOptional() + isActive?: boolean; +} \ No newline at end of file diff --git a/backend/src/carriers/dto/update-vehicle.dto.ts b/backend/src/carriers/dto/update-vehicle.dto.ts new file mode 100644 index 00000000..a9783442 --- /dev/null +++ b/backend/src/carriers/dto/update-vehicle.dto.ts @@ -0,0 +1,9 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateVehicleDto } from './create-vehicle.dto'; +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateVehicleDto extends PartialType(CreateVehicleDto) { + @IsBoolean() + @IsOptional() + isActive?: boolean; +} \ No newline at end of file diff --git a/backend/src/carriers/entities/carrier-rating.entity.ts b/backend/src/carriers/entities/carrier-rating.entity.ts new file mode 100644 index 00000000..f3c81503 --- /dev/null +++ b/backend/src/carriers/entities/carrier-rating.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Carrier } from './carrier.entity'; + +@Entity('carrier_ratings') +@Unique(['carrier_id', 'rated_by', 'freight_job_id']) +export class CarrierRating { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'carrier_id', type: 'uuid' }) + carrierId: string; + + @ManyToOne(() => Carrier, (carrier: Carrier) => carrier.ratings) + @JoinColumn({ name: 'carrier_id' }) + carrier: Carrier; + + @Column({ name: 'rated_by', type: 'uuid' }) + ratedBy: string; + + @Column({ name: 'freight_job_id', type: 'uuid', nullable: true }) + freightJobId: string; + + @Column({ type: 'int' }) + rating: number; + + @Column({ type: 'text', nullable: true }) + review: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/carriers/entities/carrier.entity.ts b/backend/src/carriers/entities/carrier.entity.ts new file mode 100644 index 00000000..d1313557 --- /dev/null +++ b/backend/src/carriers/entities/carrier.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + ManyToOne, + Unique, +} from 'typeorm'; +import { CarrierRating } from './carrier-rating.entity'; + +// Import Vehicle to satisfy TypeScript type checking despite circular dependency +import { Vehicle } from './vehicle.entity'; + +@Entity('carriers') +@Unique(['license_number']) +export class Carrier { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'company_name' }) + companyName: string; + + @Column({ name: 'license_number', unique: true }) + licenseNumber: string; + + @Column({ name: 'insurance_policy', nullable: true }) + insurancePolicy: string; + + @Column({ name: 'service_areas', type: 'jsonb', nullable: true }) + serviceAreas: string[]; + + @Column({ name: 'average_rating', type: 'decimal', precision: 3, scale: 2, default: 0 }) + averageRating: number; + + @Column({ name: 'total_deliveries', type: 'int', default: 0 }) + totalDeliveries: number; + + @Column({ name: 'on_time_percentage', type: 'decimal', precision: 5, scale: 2, default: 0 }) + onTimePercentage: number; + + @Column({ name: 'is_verified', default: false }) + isVerified: boolean; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @OneToMany(() => require('./vehicle.entity').Vehicle, (vehicle: any) => vehicle.carrier) + vehicles: Vehicle[]; + + @OneToMany(() => CarrierRating, (rating: CarrierRating) => rating.carrier) + ratings: CarrierRating[]; +} \ No newline at end of file diff --git a/backend/src/carriers/entities/vehicle.entity.ts b/backend/src/carriers/entities/vehicle.entity.ts new file mode 100644 index 00000000..917feaab --- /dev/null +++ b/backend/src/carriers/entities/vehicle.entity.ts @@ -0,0 +1,79 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; + +// Import Carrier separately to handle circular dependency + +export enum VehicleType { + TRUCK = 'truck', + VAN = 'van', + CARGO_SHIP = 'cargo_ship', + CAR = 'car', + MOTORCYCLE = 'motorcycle', + TRAILER = 'trailer' +} + +@Entity('vehicles') +export class Vehicle { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'carrier_id', type: 'uuid' }) + carrierId: string; + + @ManyToOne(() => require('./carrier.entity').Carrier, (carrier: any) => carrier.vehicles) + @JoinColumn({ name: 'carrier_id' }) + carrier: any; + + @Column({ + type: 'enum', + enum: VehicleType, + name: 'vehicle_type' + }) + vehicleType: VehicleType; + + @Column({ name: 'license_plate' }) + licensePlate: string; + + @Column({ + name: 'capacity_weight', + type: 'decimal', + precision: 10, + scale: 2, + nullable: true + }) + capacityWeight: number; + + @Column({ + name: 'capacity_volume', + type: 'decimal', + precision: 10, + scale: 2, + nullable: true + }) + capacityVolume: number; + + @Column({ name: 'year', type: 'int', nullable: true }) + year: number; + + @Column({ name: 'make', nullable: true }) + make: string; + + @Column({ name: 'model', nullable: true }) + model: string; + + @Column({ name: 'is_active', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/carriers/services/carrier.service.ts b/backend/src/carriers/services/carrier.service.ts new file mode 100644 index 00000000..4f0171e7 --- /dev/null +++ b/backend/src/carriers/services/carrier.service.ts @@ -0,0 +1,259 @@ +import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In, Not } from 'typeorm'; +import { Carrier } from '../entities/carrier.entity'; +import { Vehicle } from '../entities/vehicle.entity'; +import { CarrierRating } from '../entities/carrier-rating.entity'; +import { CreateCarrierDto } from '../dto/create-carrier.dto'; +import { UpdateCarrierDto } from '../dto/update-carrier.dto'; +import { CreateVehicleDto } from '../dto/create-vehicle.dto'; +import { UpdateVehicleDto } from '../dto/update-vehicle.dto'; +import { CreateRatingDto } from '../dto/create-rating.dto'; + +@Injectable() +export class CarrierService { + constructor( + @InjectRepository(Carrier) + private carrierRepository: Repository, + @InjectRepository(Vehicle) + private vehicleRepository: Repository, + @InjectRepository(CarrierRating) + private ratingRepository: Repository, + ) {} + + async create(createCarrierDto: CreateCarrierDto): Promise { + // Check if license number already exists + const existingCarrier = await this.carrierRepository.findOne({ + where: { licenseNumber: createCarrierDto.licenseNumber }, + }); + + if (existingCarrier) { + throw new ConflictException('License number already exists'); + } + + const carrier = this.carrierRepository.create({ + ...createCarrierDto, + serviceAreas: createCarrierDto.serviceAreas || [], + }); + + return await this.carrierRepository.save(carrier); + } + + async findAll(): Promise { + return await this.carrierRepository.find({ + relations: ['vehicles', 'ratings'], + }); + } + + async findOne(id: string): Promise { + const carrier = await this.carrierRepository.findOne({ + where: { id }, + relations: ['vehicles', 'ratings'], + }); + + if (!carrier) { + throw new NotFoundException(`Carrier with ID ${id} not found`); + } + + return carrier; + } + + async update(id: string, updateCarrierDto: UpdateCarrierDto): Promise { + const carrier = await this.findOne(id); + + Object.assign(carrier, updateCarrierDto); + + // Recalculate average rating if needed + if (updateCarrierDto.isVerified !== undefined) { + carrier.isVerified = updateCarrierDto.isVerified; + } + + if (updateCarrierDto.isActive !== undefined) { + carrier.isActive = updateCarrierDto.isActive; + } + + return await this.carrierRepository.save(carrier); + } + + async remove(id: string): Promise { + const carrier = await this.findOne(id); + + // Check if carrier has active vehicles + const activeVehicles = await this.vehicleRepository.count({ + where: { carrierId: id, isActive: true }, + }); + + if (activeVehicles > 0) { + throw new BadRequestException('Cannot delete carrier with active vehicles'); + } + + await this.carrierRepository.remove(carrier); + } + + // Vehicle methods + async createVehicle(carrierId: string, createVehicleDto: CreateVehicleDto): Promise { + // Verify carrier exists + await this.findOne(carrierId); + + const vehicle = this.vehicleRepository.create({ + ...createVehicleDto, + carrierId, + }); + + return await this.vehicleRepository.save(vehicle); + } + + async getVehiclesByCarrier(carrierId: string): Promise { + return await this.vehicleRepository.find({ + where: { carrierId, isActive: true }, + }); + } + + async getVehicleById(vehicleId: string): Promise { + const vehicle = await this.vehicleRepository.findOne({ + where: { id: vehicleId }, + }); + + if (!vehicle) { + throw new NotFoundException(`Vehicle with ID ${vehicleId} not found`); + } + + return vehicle; + } + + async updateVehicle(vehicleId: string, updateVehicleDto: UpdateVehicleDto): Promise { + const vehicle = await this.getVehicleById(vehicleId); + + Object.assign(vehicle, updateVehicleDto); + + return await this.vehicleRepository.save(vehicle); + } + + async deleteVehicle(vehicleId: string): Promise { + const vehicle = await this.getVehicleById(vehicleId); + await this.vehicleRepository.remove(vehicle); + } + + // Rating methods + async rateCarrier(createRatingDto: CreateRatingDto): Promise { + // Check if carrier exists + await this.findOne(createRatingDto.carrierId); + + // Check if user has already rated this carrier for the same job + const existingRating = await this.ratingRepository.findOne({ + where: { + carrierId: createRatingDto.carrierId, + ratedBy: createRatingDto.ratedBy, + freightJobId: createRatingDto.freightJobId, + }, + }); + + if (existingRating) { + throw new ConflictException('Rating already exists for this job'); + } + + const rating = this.ratingRepository.create(createRatingDto); + const savedRating = await this.ratingRepository.save(rating); + + // Update carrier's average rating + await this.updateCarrierAverageRating(createRatingDto.carrierId); + + return savedRating; + } + + private async updateCarrierAverageRating(carrierId: string): Promise { + const ratings = await this.ratingRepository.find({ + where: { carrierId }, + }); + + if (ratings.length === 0) { + await this.carrierRepository.update(carrierId, { averageRating: 0 }); + return; + } + + const sum = ratings.reduce((acc, rating) => acc + rating.rating, 0); + const average = sum / ratings.length; + + await this.carrierRepository.update(carrierId, { + averageRating: parseFloat(average.toFixed(2)) + }); + } + + // Performance methods + async getPerformanceMetrics(carrierId: string): Promise { + const carrier = await this.findOne(carrierId); + + // This would typically involve more complex calculations based on job completion data + // For now, returning the stored metrics + return { + averageRating: carrier.averageRating, + totalDeliveries: carrier.totalDeliveries, + onTimePercentage: carrier.onTimePercentage, + serviceAreas: carrier.serviceAreas, + isActive: carrier.isActive, + isVerified: carrier.isVerified, + }; + } + + async updateCarrierDeliveryStats(carrierId: string, onTime: boolean): Promise { + const carrier = await this.carrierRepository.findOne({ + where: { id: carrierId }, + }); + + if (!carrier) { + throw new NotFoundException(`Carrier with ID ${carrierId} not found`); + } + + // Update total deliveries + carrier.totalDeliveries += 1; + + // Update on-time percentage + const totalDeliveries = carrier.totalDeliveries; + let onTimeCount = carrier.onTimePercentage * (totalDeliveries - 1) / 100; + + if (onTime) { + onTimeCount += 1; + } + + carrier.onTimePercentage = (onTimeCount / totalDeliveries) * 100; + + await this.carrierRepository.save(carrier); + } + + // Search and filtering methods + async searchCarriers(filters: { + serviceArea?: string; + minRating?: number; + isVerified?: boolean; + searchTerm?: string; + }): Promise { + const queryBuilder = this.carrierRepository.createQueryBuilder('carrier'); + + if (filters.serviceArea) { + queryBuilder.andWhere('carrier.service_areas && :areas', { + areas: `{${filters.serviceArea}}` + }); + } + + if (filters.minRating !== undefined) { + queryBuilder.andWhere('carrier.average_rating >= :minRating', { + minRating: filters.minRating + }); + } + + if (filters.isVerified !== undefined) { + queryBuilder.andWhere('carrier.is_verified = :isVerified', { + isVerified: filters.isVerified + }); + } + + if (filters.searchTerm) { + queryBuilder.andWhere( + '(carrier.company_name ILIKE :searchTerm OR carrier.license_number ILIKE :searchTerm)', + { searchTerm: `%${filters.searchTerm}%` } + ); + } + + return await queryBuilder.getMany(); + } +} \ No newline at end of file diff --git a/backend/test/carrier.e2e-spec.ts b/backend/test/carrier.e2e-spec.ts new file mode 100644 index 00000000..cbfdb6f9 --- /dev/null +++ b/backend/test/carrier.e2e-spec.ts @@ -0,0 +1,66 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('CarrierController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('/carriers (POST) - should create a new carrier', async () => { + const createCarrierDto = { + userId: 'test-user-id', + companyName: 'Test Carrier Company', + licenseNumber: 'TEST123456', + insurancePolicy: 'POLICY123', + serviceAreas: ['New York', 'New Jersey'], + }; + + return request(app.getHttpServer()) + .post('/carriers') + .send(createCarrierDto) + .expect(201); + }); + + it('/carriers (GET) - should return all carriers', async () => { + return request(app.getHttpServer()) + .get('/carriers') + .expect(200); + }); + + it('/carriers/:id (GET) - should return a carrier by id', async () => { + // This test assumes there's a carrier in the database + return request(app.getHttpServer()) + .get('/carriers/non-existent-id') + .expect(404); + }); + + it('/carriers/:id (PATCH) - should update a carrier', async () => { + const updateCarrierDto = { + companyName: 'Updated Carrier Name', + }; + + return request(app.getHttpServer()) + .patch('/carriers/non-existent-id') + .send(updateCarrierDto) + .expect(404); + }); + + it('/carriers/:id (DELETE) - should delete a carrier', async () => { + return request(app.getHttpServer()) + .delete('/carriers/non-existent-id') + .expect(404); + }); +}); \ No newline at end of file