feat: add updateCategoryMine endpoint to manage product category updates
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Implemented a new PATCH endpoint in ProductsController to update the category of a user's product. - Added corresponding service method in ProductsService to handle business logic and validation. - Created UpdateCategoryMineDto for request validation. - Enhanced error handling for forbidden actions and not found resources. - Updated API error mapping in Flutter to handle specific forbidden messages. - Modified ProductPickerField to allow product creation directly from the picker. - Added tests for the new endpoint and service method to ensure proper functionality and error handling.
This commit is contained in:
Generated
+279
@@ -38,8 +38,10 @@
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.15.29",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.2.6",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
@@ -2033,6 +2035,19 @@
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||
}
|
||||
},
|
||||
"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,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@nuxtjs/opencollective": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz",
|
||||
@@ -2051,6 +2066,16 @@
|
||||
"npm": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
|
||||
@@ -2285,6 +2310,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/eslint": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
@@ -2411,6 +2443,13 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
@@ -2523,6 +2562,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "8.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
|
||||
"integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cookiejar": "^2.1.5",
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supertest": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz",
|
||||
"integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
|
||||
@@ -2906,6 +2969,20 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/babel-jest": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
|
||||
@@ -3617,6 +3694,19 @@
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||
@@ -3644,6 +3734,16 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -3745,6 +3845,13 @@
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookiejar": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -3893,6 +4000,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
@@ -3931,6 +4048,17 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-sequences": {
|
||||
"version": "29.6.3",
|
||||
"resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
|
||||
@@ -4074,6 +4202,22 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -4465,6 +4609,41 @@
|
||||
"webpack": "^5.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -4779,6 +4958,22 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -7843,6 +8038,90 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.1",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.7",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.5",
|
||||
"formidable": "^3.5.4",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.14.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "7.2.2",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
|
||||
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie-signature": "^1.2.2",
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^10.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supertest/node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
||||
+16
-10
@@ -18,20 +18,20 @@
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@prisma/client": "6.12.0",
|
||||
"prisma": "6.12.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"helmet": "^8.0.0",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"prisma": "6.12.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"uuid": "^11.1.0",
|
||||
"helmet": "^8.0.0",
|
||||
"@nestjs/throttler": "^6.4.0"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
@@ -39,20 +39,26 @@
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.15.29",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"typescript": "^5.4.5",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.2.6",
|
||||
"@types/jest": "^29.5.14"
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node",
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"moduleFileExtensions": ["js", "json", "ts"]
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class UpdateCategoryMineDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
categoryId!: number;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
|
||||
import { BulkUpdateProductsDto } from './dto/bulk-update-products.dto';
|
||||
import { AiCategorizeBulkDto } from './dto/ai-categorize-bulk.dto';
|
||||
import { SetProductStatusDto } from './dto/set-product-status.dto';
|
||||
import { UpdateCategoryMineDto } from './dto/update-category-mine.dto';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
import { CategoriesService } from '../categories/categories.service';
|
||||
@@ -166,6 +167,15 @@ export class ProductsController {
|
||||
return this.productsService.mergePrivate(req.user.id, body.sourceProductId, body.targetProductId);
|
||||
}
|
||||
|
||||
@Patch('mine/:id/category')
|
||||
updateCategoryMine(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdateCategoryMineDto,
|
||||
@Request() req: { user: { id: number } },
|
||||
) {
|
||||
return this.productsService.updateCategoryMine(req.user.id, id, body.categoryId);
|
||||
}
|
||||
|
||||
@Roles('admin')
|
||||
@Post('merge')
|
||||
merge(@Body() body: MergeProductsDto) {
|
||||
|
||||
@@ -10,6 +10,7 @@ describe('Products controller security', () => {
|
||||
createPending: jest.fn(),
|
||||
updateCanonicalNamePrivate: jest.fn(),
|
||||
mergePrivate: jest.fn(),
|
||||
updateCategoryMine: jest.fn(),
|
||||
};
|
||||
|
||||
const aiServiceMock = {
|
||||
@@ -107,6 +108,14 @@ describe('Products controller security', () => {
|
||||
expect(productsServiceMock.mergePrivate).toHaveBeenCalledWith(42, 10, 20);
|
||||
});
|
||||
|
||||
it('updateCategoryMine vidarebefordrar req.user.id', () => {
|
||||
productsServiceMock.updateCategoryMine.mockResolvedValue({ id: 1, categoryId: 5 });
|
||||
|
||||
controller.updateCategoryMine(1, { categoryId: 5 } as any, { user: { id: 42 } } as any);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).toHaveBeenCalledWith(42, 1, 5);
|
||||
});
|
||||
|
||||
it('suggestCategory använder canonicalName fallback name', async () => {
|
||||
productsServiceMock.findOne.mockResolvedValue({ id: 1, name: 'Mjolk', canonicalName: 'Mjolk 1L' });
|
||||
categoriesServiceMock.findFlattened.mockResolvedValue([{ id: 1, name: 'Mejeri' }]);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
describe('ProductsService.updateCategoryMine', () => {
|
||||
const prismaMock = {
|
||||
product: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
category: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const aiServiceMock = {
|
||||
suggestCategory: jest.fn(),
|
||||
};
|
||||
|
||||
const categoriesServiceMock = {
|
||||
findFlattened: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new ProductsService(
|
||||
prismaMock as any,
|
||||
aiServiceMock as any,
|
||||
categoriesServiceMock as any,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('kastar NotFound om produkt saknas', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 5)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('kastar Forbidden om produkten inte ägs av användaren', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue({
|
||||
id: 100,
|
||||
ownerId: 7,
|
||||
isPrivate: true,
|
||||
});
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 5)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('kastar Forbidden för globala produkter', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue({
|
||||
id: 100,
|
||||
ownerId: 42,
|
||||
isPrivate: false,
|
||||
});
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 5)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(prismaMock.product.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('kastar NotFound om kategori saknas', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue({
|
||||
id: 100,
|
||||
ownerId: 42,
|
||||
isPrivate: true,
|
||||
});
|
||||
prismaMock.category.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 9999)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prismaMock.product.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uppdaterar kategori för ägd icke-global produkt', async () => {
|
||||
prismaMock.product.findUnique.mockResolvedValue({
|
||||
id: 100,
|
||||
ownerId: 42,
|
||||
isPrivate: true,
|
||||
});
|
||||
prismaMock.category.findUnique.mockResolvedValue({ id: 5 });
|
||||
prismaMock.product.update.mockResolvedValue({ id: 100, categoryId: 5 });
|
||||
|
||||
await expect(service.updateCategoryMine(42, 100, 5)).resolves.toEqual({
|
||||
id: 100,
|
||||
categoryId: 5,
|
||||
});
|
||||
expect(prismaMock.product.update).toHaveBeenCalledWith({
|
||||
where: { id: 100 },
|
||||
data: { categoryId: 5 },
|
||||
select: { id: true, categoryId: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -628,4 +628,40 @@ export class ProductsService {
|
||||
|
||||
return this._mergeCore(sourceProductId, targetProductId);
|
||||
}
|
||||
|
||||
async updateCategoryMine(userId: number, id: number, categoryId: number) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, ownerId: true, isPrivate: true },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException(`Produkt med id ${id} hittades inte`);
|
||||
}
|
||||
|
||||
if (product.ownerId !== userId) {
|
||||
throw new ForbiddenException('Du kan bara omkategorisera dina egna produkter');
|
||||
}
|
||||
|
||||
const isGlobal = !product.isPrivate;
|
||||
if (isGlobal) {
|
||||
throw new ForbiddenException(
|
||||
'Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.',
|
||||
);
|
||||
}
|
||||
|
||||
const category = await this.prisma.category.findUnique({
|
||||
where: { id: categoryId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!category) {
|
||||
throw new NotFoundException(`Kategori med id ${categoryId} hittades inte`);
|
||||
}
|
||||
|
||||
return this.prisma.product.update({
|
||||
where: { id },
|
||||
data: { categoryId },
|
||||
select: { id: true, categoryId: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
INestApplication,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import request = require('supertest');
|
||||
|
||||
import { AiService } from '../ai/ai.service';
|
||||
import { CategoriesService } from '../categories/categories.service';
|
||||
import { ProductsController } from './products.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
class FakeJwtGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
req.user = { id: 42 };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ProductsController HTTP: PATCH /products/mine/:id/category', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
const productsServiceMock = {
|
||||
updateCategoryMine: jest.fn(),
|
||||
};
|
||||
|
||||
const aiServiceMock = {
|
||||
suggestCategory: jest.fn(),
|
||||
};
|
||||
|
||||
const categoriesServiceMock = {
|
||||
findFlattened: jest.fn(),
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProductsController],
|
||||
providers: [
|
||||
{ provide: ProductsService, useValue: productsServiceMock },
|
||||
{ provide: AiService, useValue: aiServiceMock },
|
||||
{ provide: CategoriesService, useValue: categoriesServiceMock },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalGuards(new FakeJwtGuard());
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
productsServiceMock.updateCategoryMine.mockResolvedValue({ id: 11, categoryId: 5 });
|
||||
});
|
||||
|
||||
it('accepterar giltig payload och skickar userId + id + categoryId till service', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({ categoryId: '5' })
|
||||
.expect(200)
|
||||
.expect({ id: 11, categoryId: 5 });
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).toHaveBeenCalledWith(42, 11, 5);
|
||||
});
|
||||
|
||||
it('nekar payload utan categoryId', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({})
|
||||
.expect(400);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nekar ogiltig path-param id (ParseIntPipe)', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/not-a-number/category')
|
||||
.send({ categoryId: 5 })
|
||||
.expect(400);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nekar payload med categoryId <= 0', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({ categoryId: 0 })
|
||||
.expect(400);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nekar payload med extra fält (whitelist/forbidNonWhitelisted)', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({ categoryId: 5, isAdmin: true })
|
||||
.expect(400);
|
||||
|
||||
expect(productsServiceMock.updateCategoryMine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagerar Forbidden från service', async () => {
|
||||
productsServiceMock.updateCategoryMine.mockRejectedValueOnce(
|
||||
new ForbiddenException('Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.'),
|
||||
);
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.patch('/products/mine/11/category')
|
||||
.send({ categoryId: 5 })
|
||||
.expect(403);
|
||||
|
||||
expect(res.body.message).toBe(
|
||||
'Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,11 @@ import 'package:flutter/services.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
const _safeForbiddenMessages = {
|
||||
'Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.',
|
||||
'Du kan bara omkategorisera dina egna produkter',
|
||||
};
|
||||
|
||||
String mapErrorToUserMessage(Object error, BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
if (error is ApiException) {
|
||||
@@ -11,7 +16,9 @@ String mapErrorToUserMessage(Object error, BuildContext context) {
|
||||
case ApiErrorType.unauthorized:
|
||||
return l10n.sessionExpiredError;
|
||||
case ApiErrorType.forbidden:
|
||||
return l10n.forbiddenError;
|
||||
return _safeForbiddenMessages.contains(error.message)
|
||||
? error.message
|
||||
: l10n.forbiddenError;
|
||||
case ApiErrorType.server:
|
||||
return l10n.serverError;
|
||||
case ApiErrorType.network:
|
||||
|
||||
@@ -13,6 +13,7 @@ class ProductApiPaths {
|
||||
static const deleted = '/products/deleted';
|
||||
static const merge = '/products/merge';
|
||||
static const mergePrivate = '/products/private/merge';
|
||||
static String updateMineCategory(int id) => '/products/mine/$id/category';
|
||||
static String mergePreview(int sourceProductId, int targetProductId) =>
|
||||
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
|
||||
static String setStatus(int id) => '/products/$id/status';
|
||||
|
||||
@@ -36,6 +36,9 @@ class ProductPickerField extends StatelessWidget {
|
||||
/// If set, the picker bottom sheet opens with this text pre-filled in the search field.
|
||||
final String? initialQuery;
|
||||
|
||||
/// Optional callback to create a new product directly from the picker.
|
||||
final Future<ProductOption?> Function(String name)? onCreate;
|
||||
|
||||
const ProductPickerField({
|
||||
super.key,
|
||||
required this.products,
|
||||
@@ -46,6 +49,7 @@ class ProductPickerField extends StatelessWidget {
|
||||
this.label = 'Produkt',
|
||||
this.errorText,
|
||||
this.initialQuery,
|
||||
this.onCreate,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -57,7 +61,7 @@ class ProductPickerField extends StatelessWidget {
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
final interactive = enabled && !isLoading && products.isNotEmpty;
|
||||
final interactive = enabled && !isLoading && (products.isNotEmpty || onCreate != null);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: interactive ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
@@ -81,7 +85,9 @@ class ProductPickerField extends StatelessWidget {
|
||||
: selected == null
|
||||
? Text(
|
||||
products.isEmpty
|
||||
? 'Inga produkter tillgängliga'
|
||||
? (onCreate != null
|
||||
? 'Inga produkter tillgängliga. Tryck för att välja eller skapa.'
|
||||
: 'Inga produkter tillgängliga')
|
||||
: 'Tryck för att välja produkt',
|
||||
)
|
||||
: Text(selected.name),
|
||||
@@ -98,6 +104,7 @@ class ProductPickerField extends StatelessWidget {
|
||||
value: value,
|
||||
label: label,
|
||||
initialQuery: initialQuery,
|
||||
onCreate: onCreate,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (result == null) return;
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../data/inventory_providers.dart';
|
||||
import '../../import/data/receipt_import_session.dart' show ImportDestination;
|
||||
import 'inventory_category_helpers.dart';
|
||||
import 'inventory_category_product_section.dart';
|
||||
import 'inventory_product_mutations.dart';
|
||||
|
||||
class CreateInventoryScreen extends ConsumerStatefulWidget {
|
||||
final String? initialDestination;
|
||||
@@ -185,18 +186,24 @@ class _CreateInventoryScreenState
|
||||
if (selected == null || !mounted) return;
|
||||
setState(() {
|
||||
_selectedCategoryId = selected.id;
|
||||
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||
final current = _selectedProduct();
|
||||
final currentCategoryId = tryParseDynamicInt(current?['categoryId']);
|
||||
if (!productInSelectedBranch(
|
||||
productCategoryId: currentCategoryId,
|
||||
selectedCategoryIds: selectedCategoryIds,
|
||||
)) {
|
||||
_selectedProductId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _syncSelectedProductCategory(String? token) async {
|
||||
await syncSelectedProductCategory(
|
||||
ref: ref,
|
||||
token: token,
|
||||
selectedProductId: _selectedProductId,
|
||||
selectedCategoryId: _selectedCategoryId,
|
||||
products: _products,
|
||||
tryParseDynamicInt: tryParseDynamicInt,
|
||||
setProducts: (updated) {
|
||||
if (!mounted) return;
|
||||
setState(() => _products = updated);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate(bool isBestBefore) async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
@@ -215,6 +222,21 @@ class _CreateInventoryScreenState
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProductOption?> _createProductInSelectedCategory(String rawName) async {
|
||||
return createProductInSelectedCategory(
|
||||
ref: ref,
|
||||
context: context,
|
||||
rawName: rawName,
|
||||
selectedCategoryId: _selectedCategoryId,
|
||||
products: _products,
|
||||
tryParseDynamicInt: tryParseDynamicInt,
|
||||
setProducts: (updated) {
|
||||
if (!mounted) return;
|
||||
setState(() => _products = updated);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_selectedProductId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -253,6 +275,7 @@ class _CreateInventoryScreenState
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
await _syncSelectedProductCategory(token);
|
||||
final body = <String, dynamic>{
|
||||
'productId': _selectedProductId,
|
||||
'quantity':
|
||||
@@ -351,6 +374,7 @@ class _CreateInventoryScreenState
|
||||
productOptions: productOptions,
|
||||
selectedProductId: _selectedProductId,
|
||||
onProductChanged: (value) => setState(() => _selectedProductId = value),
|
||||
onCreateProduct: _selectedCategoryId == null ? null : _createProductInSelectedCategory,
|
||||
isLoadingProducts: _loadingProducts,
|
||||
productEnabled: !_saving,
|
||||
productLabel: context.l10n.inventoryProductLabel,
|
||||
|
||||
@@ -17,6 +17,7 @@ class InventoryCategoryProductSection extends StatelessWidget {
|
||||
final List<product_picker.ProductOption> productOptions;
|
||||
final int? selectedProductId;
|
||||
final ValueChanged<int?> onProductChanged;
|
||||
final Future<product_picker.ProductOption?> Function(String name)? onCreateProduct;
|
||||
final bool isLoadingProducts;
|
||||
final bool productEnabled;
|
||||
final String productLabel;
|
||||
@@ -36,6 +37,7 @@ class InventoryCategoryProductSection extends StatelessWidget {
|
||||
required this.productOptions,
|
||||
required this.selectedProductId,
|
||||
required this.onProductChanged,
|
||||
this.onCreateProduct,
|
||||
required this.isLoadingProducts,
|
||||
required this.productEnabled,
|
||||
required this.productLabel,
|
||||
@@ -78,6 +80,7 @@ class InventoryCategoryProductSection extends StatelessWidget {
|
||||
enabled: productEnabled,
|
||||
label: productLabel,
|
||||
onChanged: onProductChanged,
|
||||
onCreate: onCreateProduct,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../data/inventory_providers.dart';
|
||||
import '../domain/inventory_item.dart';
|
||||
import 'inventory_category_helpers.dart';
|
||||
import 'inventory_category_product_section.dart';
|
||||
import 'inventory_product_mutations.dart';
|
||||
|
||||
class InventoryEditScreen extends ConsumerStatefulWidget {
|
||||
final int itemId;
|
||||
@@ -206,18 +207,24 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
if (selected == null || !mounted) return;
|
||||
setState(() {
|
||||
_selectedCategoryId = selected.id;
|
||||
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||
final current = _selectedProduct();
|
||||
final currentCategoryId = tryParseDynamicInt(current?['categoryId']);
|
||||
if (!productInSelectedBranch(
|
||||
productCategoryId: currentCategoryId,
|
||||
selectedCategoryIds: selectedCategoryIds,
|
||||
)) {
|
||||
_selectedProductId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _syncSelectedProductCategory(String? token) async {
|
||||
await syncSelectedProductCategory(
|
||||
ref: ref,
|
||||
token: token,
|
||||
selectedProductId: _selectedProductId,
|
||||
selectedCategoryId: _selectedCategoryId,
|
||||
products: _products,
|
||||
tryParseDynamicInt: tryParseDynamicInt,
|
||||
setProducts: (updated) {
|
||||
if (!mounted) return;
|
||||
setState(() => _products = updated);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate(bool isBestBefore) async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
@@ -236,6 +243,21 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProductOption?> _createProductInSelectedCategory(String rawName) async {
|
||||
return createProductInSelectedCategory(
|
||||
ref: ref,
|
||||
context: context,
|
||||
rawName: rawName,
|
||||
selectedCategoryId: _selectedCategoryId,
|
||||
products: _products,
|
||||
tryParseDynamicInt: tryParseDynamicInt,
|
||||
setProducts: (updated) {
|
||||
if (!mounted) return;
|
||||
setState(() => _products = updated);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_selectedProductId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -248,6 +270,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
await _syncSelectedProductCategory(token);
|
||||
final body = <String, dynamic>{
|
||||
'productId': _selectedProductId,
|
||||
'quantity':
|
||||
@@ -343,6 +366,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
productOptions: _productOptions(),
|
||||
selectedProductId: _selectedProductId,
|
||||
onProductChanged: (value) => setState(() => _selectedProductId = value),
|
||||
onCreateProduct: _selectedCategoryId == null ? null : _createProductInSelectedCategory,
|
||||
isLoadingProducts: _loadingProducts,
|
||||
productEnabled: !_saving,
|
||||
productLabel: context.l10n.inventoryProductLabel,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../../core/api/api_paths.dart';
|
||||
import '../../../core/api/api_providers.dart';
|
||||
import '../../../core/ui/product_picker_field.dart' show ProductOption;
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
|
||||
Future<void> syncSelectedProductCategory({
|
||||
required WidgetRef ref,
|
||||
required String? token,
|
||||
required int? selectedProductId,
|
||||
required int? selectedCategoryId,
|
||||
required List<Map<String, dynamic>> products,
|
||||
required int? Function(dynamic) tryParseDynamicInt,
|
||||
required void Function(List<Map<String, dynamic>>) setProducts,
|
||||
}) async {
|
||||
final productId = selectedProductId;
|
||||
final categoryId = selectedCategoryId;
|
||||
if (productId == null || categoryId == null || token == null || token.isEmpty) return;
|
||||
|
||||
final current = products.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(p) => tryParseDynamicInt(p?['id']) == productId,
|
||||
orElse: () => null,
|
||||
);
|
||||
final currentCategoryId = tryParseDynamicInt(current?['categoryId']);
|
||||
if (currentCategoryId == categoryId) return;
|
||||
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.patchJson(
|
||||
ProductApiPaths.updateMineCategory(productId),
|
||||
body: {'categoryId': categoryId},
|
||||
token: token,
|
||||
);
|
||||
|
||||
final updatedProducts = products
|
||||
.map(
|
||||
(p) => tryParseDynamicInt(p['id']) == productId
|
||||
? {...p, 'categoryId': categoryId}
|
||||
: p,
|
||||
)
|
||||
.toList();
|
||||
setProducts(updatedProducts);
|
||||
}
|
||||
|
||||
Future<ProductOption?> createProductInSelectedCategory({
|
||||
required WidgetRef ref,
|
||||
required BuildContext context,
|
||||
required String rawName,
|
||||
required int? selectedCategoryId,
|
||||
required List<Map<String, dynamic>> products,
|
||||
required int? Function(dynamic) tryParseDynamicInt,
|
||||
required void Function(List<Map<String, dynamic>>) setProducts,
|
||||
}) async {
|
||||
final name = rawName.trim();
|
||||
final categoryId = selectedCategoryId;
|
||||
if (name.isEmpty || categoryId == null) return null;
|
||||
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.postJson(
|
||||
ProductApiPaths.createPrivate,
|
||||
body: {
|
||||
'name': name,
|
||||
'categoryId': categoryId,
|
||||
},
|
||||
token: token,
|
||||
);
|
||||
|
||||
final created = Map<String, dynamic>.from(data as Map);
|
||||
final createdId = tryParseDynamicInt(created['id']);
|
||||
if (createdId == null) return null;
|
||||
|
||||
setProducts([...products, created]);
|
||||
|
||||
return (
|
||||
id: createdId,
|
||||
name: (created['canonicalName'] ?? created['name'] ?? '').toString(),
|
||||
categoryId: tryParseDynamicInt(created['categoryId']),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user