From 1e4cc2fc80372613f26880c3cce3c9c1c0d1f2c6 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Wed, 16 Feb 2022 22:45:19 +0100 Subject: [PATCH] feat: add and remove products to/from collection in bulk endpoints (#1032) * adds bulk add/remove products to/from collection. Adds endpoint updateProducts on collections that uses these bulk operations * fix integration tests and test description * undo change to swap * made requested changes * added removeProducts endpoint * made requested changes * fix: set collection_id null * updated collection_id to type string | undefined --- .../admin/__snapshots__/colllections.js.snap | 107 ++++++++++++++++++ .../api/__tests__/admin/colllections.js | 95 +++++++++++++--- integration-tests/api/package.json | 6 +- integration-tests/api/yarn.lock | 70 ++++++------ .../collections/__tests__/add-products.js | 91 +++++++++++++++ .../collections/__tests__/remove-products.js | 64 +++++++++++ .../routes/admin/collections/add-products.ts | 52 +++++++++ .../src/api/routes/admin/collections/index.ts | 3 + .../admin/collections/remove-products.ts | 56 +++++++++ packages/medusa/src/models/product.ts | 28 +++-- packages/medusa/src/repositories/product.ts | 41 ++++++- .../services/__mocks__/product-collection.js | 12 +- .../medusa/src/services/product-collection.js | 26 +++++ packages/medusa/src/services/swap.js | 2 +- 14 files changed, 580 insertions(+), 73 deletions(-) create mode 100644 packages/medusa/src/api/routes/admin/collections/__tests__/add-products.js create mode 100644 packages/medusa/src/api/routes/admin/collections/__tests__/remove-products.js create mode 100644 packages/medusa/src/api/routes/admin/collections/add-products.ts create mode 100644 packages/medusa/src/api/routes/admin/collections/remove-products.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/colllections.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/colllections.js.snap index bbd0467bf2..10891cc07a 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/colllections.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/colllections.js.snap @@ -1,5 +1,102 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`/admin/collections /admin/collections adds products to collection 1`] = ` +Object { + "collection": Object { + "created_at": Any, + "deleted_at": null, + "handle": "test-collection", + "id": "test-collection", + "metadata": null, + "products": Array [ + Object { + "collection_id": "test-collection", + "created_at": Any, + "deleted_at": null, + "description": "test-product-description", + "discountable": true, + "external_id": null, + "handle": "test-product", + "height": null, + "hs_code": null, + "id": "test-product", + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "origin_country": null, + "profile_id": StringMatching /\\^sp_\\*/, + "status": "draft", + "subtitle": null, + "thumbnail": null, + "title": "Test product", + "type_id": "test-type", + "updated_at": Any, + "weight": null, + "width": null, + }, + Object { + "collection_id": "test-collection", + "created_at": Any, + "deleted_at": null, + "description": "test-product-description1", + "discountable": true, + "external_id": null, + "handle": "test-product1", + "height": null, + "hs_code": null, + "id": "test-product1", + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "origin_country": null, + "profile_id": StringMatching /\\^sp_\\*/, + "status": "draft", + "subtitle": null, + "thumbnail": null, + "title": "Test product1", + "type_id": "test-type", + "updated_at": Any, + "weight": null, + "width": null, + }, + Object { + "collection_id": "test-collection", + "created_at": Any, + "deleted_at": null, + "description": "test-product-description", + "discountable": true, + "external_id": null, + "handle": "test-product_filtering_1", + "height": null, + "hs_code": null, + "id": "test-product_filtering_1", + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "origin_country": null, + "profile_id": StringMatching /\\^sp_\\*/, + "status": "proposed", + "subtitle": null, + "thumbnail": null, + "title": "Test product filtering 1", + "type_id": "test-type", + "updated_at": Any, + "weight": null, + "width": null, + }, + ], + "title": "Test collection", + "updated_at": Any, + }, +} +`; + exports[`/admin/collections /admin/collections creates a collection 1`] = ` Object { "collection": Object { @@ -259,6 +356,16 @@ Object { } `; +exports[`/admin/collections /admin/collections removes products from collection 1`] = ` +Object { + "id": "test-collection", + "object": "product-collection", + "removed_products": Array [ + "test-product", + ], +} +`; + exports[`/admin/collections /admin/collections/:id gets collection 1`] = ` Object { "collection": Object { diff --git a/integration-tests/api/__tests__/admin/colllections.js b/integration-tests/api/__tests__/admin/colllections.js index 38fb714a79..dd6ede0bb4 100644 --- a/integration-tests/api/__tests__/admin/colllections.js +++ b/integration-tests/api/__tests__/admin/colllections.js @@ -108,19 +108,19 @@ describe("/admin/collections", () => { created_at: expect.any(String), updated_at: expect.any(String), products: [ - { - collection_id: "test-collection", - created_at: expect.any(String), - updated_at: expect.any(String), - profile_id: expect.stringMatching(/^sp_*/), - }, - { - collection_id: "test-collection", - created_at: expect.any(String), - updated_at: expect.any(String), - profile_id: expect.stringMatching(/^sp_*/), - }, - ], + { + collection_id: "test-collection", + created_at: expect.any(String), + updated_at: expect.any(String), + profile_id: expect.stringMatching(/^sp_*/), + }, + { + collection_id: "test-collection", + created_at: expect.any(String), + updated_at: expect.any(String), + profile_id: expect.stringMatching(/^sp_*/), + }, + ], }, }) }) @@ -237,6 +237,75 @@ describe("/admin/collections", () => { }) }) + it("adds products to collection", async () => { + const api = useApi() + + // adds product test-product-filterid-1 + const response = await api + .post( + "/admin/collections/test-collection/products/batch", + { + product_ids: ["test-product_filtering_1"], + }, + { + headers: { Authorization: "Bearer test_token" }, + } + ) + .catch((err) => console.warn(err)) + + expect(response.data).toMatchSnapshot({ + collection: { + id: "test-collection", + created_at: expect.any(String), + updated_at: expect.any(String), + products: [ + { + collection_id: "test-collection", + id: "test-product", + created_at: expect.any(String), + updated_at: expect.any(String), + profile_id: expect.stringMatching(/^sp_*/), + }, + { + collection_id: "test-collection", + id: "test-product1", + created_at: expect.any(String), + updated_at: expect.any(String), + profile_id: expect.stringMatching(/^sp_*/), + }, + { + collection_id: "test-collection", + id: "test-product_filtering_1", + created_at: expect.any(String), + updated_at: expect.any(String), + profile_id: expect.stringMatching(/^sp_*/), + }, + ], + }, + }) + + expect(response.status).toEqual(200) + }) + + it("removes products from collection", async () => { + const api = useApi() + + const response = await api + .delete("/admin/collections/test-collection/products/batch", { + headers: { Authorization: "Bearer test_token" }, + data: { product_ids: ["test-product"] }, + }) + .catch((err) => console.warn(err)) + + expect(response.data).toMatchSnapshot({ + id: "test-collection", + object: "product-collection", + removed_products: ["test-product"], + }) + + expect(response.status).toEqual(200) + }) + it("filters collections by title", async () => { const api = useApi() diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index cd8ef7cdf0..4b926ef426 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -8,15 +8,15 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { - "@medusajs/medusa": "1.1.62-dev-1643977380526", - "medusa-interfaces": "1.1.32-dev-1643977380526", + "@medusajs/medusa": "1.1.64-dev-1644230658795", + "medusa-interfaces": "1.1.34-dev-1644230658795", "typeorm": "^0.2.31" }, "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/node": "^7.12.10", - "babel-preset-medusa-package": "1.1.19-dev-1643977380526", + "babel-preset-medusa-package": "1.1.19-dev-1644230658795", "jest": "^26.6.3" } } diff --git a/integration-tests/api/yarn.lock b/integration-tests/api/yarn.lock index 6b302d352a..51de594c13 100644 --- a/integration-tests/api/yarn.lock +++ b/integration-tests/api/yarn.lock @@ -1256,10 +1256,10 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@medusajs/medusa-cli@1.1.27-dev-1643977380526": - version "1.1.27-dev-1643977380526" - resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.1.27-dev-1643977380526.tgz#a74b506cef603ac1630eedb0329cfe0243c51de2" - integrity sha512-n3clGbZYiWmr0BSyvejd0insnrjcx0MVir9LCkfXPkgnghkdC+C9lIoRAduceSnha9fxoz2XjfapV3VHeAvYoQ== +"@medusajs/medusa-cli@1.1.27-dev-1644230658795": + version "1.1.27-dev-1644230658795" + resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.1.27-dev-1644230658795.tgz#dc1fed2e68d4f3fa134786d07c3a252aa2f07354" + integrity sha512-m+DqNNdpGO0wubizrPwQoBad0LrGpyut9tdI24U6a0202Dbr5DL+ekW5Lgm8VEwNd/cIiHkI5Trym/hXsnun+w== dependencies: "@babel/polyfill" "^7.8.7" "@babel/runtime" "^7.9.6" @@ -1277,8 +1277,8 @@ is-valid-path "^0.1.1" joi-objectid "^3.0.1" meant "^1.0.1" - medusa-core-utils "1.1.31-dev-1643977380526" - medusa-telemetry "0.0.11-dev-1643977380526" + medusa-core-utils "1.1.31-dev-1644230658795" + medusa-telemetry "0.0.11-dev-1644230658795" netrc-parser "^3.1.6" open "^8.0.6" ora "^5.4.1" @@ -1292,13 +1292,13 @@ winston "^3.3.3" yargs "^15.3.1" -"@medusajs/medusa@1.1.62-dev-1643977380526": - version "1.1.62-dev-1643977380526" - resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.1.62-dev-1643977380526.tgz#db32cba8212ba20694b81bb7a64ec40f5846abc0" - integrity sha512-Dy/PWcTlMk7sW+Y3dgt8ypQZnuOqB9mtCDz+1yDkgLkVbUWWHbe/8kGIkAmwvetKTJJt6FoaEgCHlEaD72zulQ== +"@medusajs/medusa@1.1.64-dev-1644230658795": + version "1.1.64-dev-1644230658795" + resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.1.64-dev-1644230658795.tgz#10f957947ce9521bc8fe109737765647b98d3d7e" + integrity sha512-gIuCzyEGT/lXG0yBGOdQ5H9pUg2WTYz/Q5nH8d2sRGDhW2A47cLOJb8VYsqrx6EcSNirW4bygoZgo24gyL5PFg== dependencies: "@hapi/joi" "^16.1.8" - "@medusajs/medusa-cli" "1.1.27-dev-1643977380526" + "@medusajs/medusa-cli" "1.1.27-dev-1644230658795" "@types/lodash" "^4.14.168" awilix "^4.2.3" body-parser "^1.19.0" @@ -1322,8 +1322,8 @@ joi "^17.3.0" joi-objectid "^3.0.1" jsonwebtoken "^8.5.1" - medusa-core-utils "1.1.31-dev-1643977380526" - medusa-test-utils "1.1.37-dev-1643977380526" + medusa-core-utils "1.1.31-dev-1644230658795" + medusa-test-utils "1.1.37-dev-1644230658795" morgan "^1.9.1" multer "^1.4.2" passport "^0.4.0" @@ -1947,10 +1947,10 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" -babel-preset-medusa-package@1.1.19-dev-1643977380526: - version "1.1.19-dev-1643977380526" - resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1643977380526.tgz#5db5e61d37a81d47e37f14cf25066fac36cc3743" - integrity sha512-WmowYmKs5I6MMqiCPWgLCzD14LFhnAbqJ+BiJnR+XgXZdIs90y/9QIrDYjvmm+hhuit0GYRfsVvqgB+swAlzyQ== +babel-preset-medusa-package@1.1.19-dev-1644230658795: + version "1.1.19-dev-1644230658795" + resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1644230658795.tgz#11b437ba399ed335c2ca8ecd0ced8e2c94557e68" + integrity sha512-NSvAyqCQgnkGGAN5/Vs6QHT/KoG8AmPnuvMSooT2QBRB2h/vULVI4UuP7CQb3mfnfjX/gm9Tbch/zHhQJKPRUw== dependencies: "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-decorators" "^7.12.1" @@ -5135,25 +5135,25 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -medusa-core-utils@1.1.31-dev-1643977380526: - version "1.1.31-dev-1643977380526" - resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1643977380526.tgz#486812e5df4c5fb276591d3448a466d7df1a9c70" - integrity sha512-EilnAOhs8Z8F6BaO4GpxdlZl2UTaOCDA0AUoYHr3FwA4VIx1ZrHWFaJKcZH3ZPhtkPiAfj0hJYIrIlWZiYL2AQ== +medusa-core-utils@1.1.31-dev-1644230658795: + version "1.1.31-dev-1644230658795" + resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1644230658795.tgz#fdeb0df1976c0331c3ce975f368429adcc4732f8" + integrity sha512-DdaTYsepwqpJGg2Hk6fa5ODVZZr8vKOwTKuAdlE45+LcildU625/2fdHYI8fUdBuOqKMnZRwWqI06I77xlpHgw== dependencies: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-interfaces@1.1.32-dev-1643977380526: - version "1.1.32-dev-1643977380526" - resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.1.32-dev-1643977380526.tgz#fa4e9d1fc78d49bb6acb6f00c9523ae7155018a2" - integrity sha512-Sy25gHF66/ZBzYNKo8TE7sVu5Gz9H4TKGBW2FpIlTJJ7rJKcaKutX71Qc2JT7KEgJ/Jx5Bu9OJaZlYaqbtmiFQ== +medusa-interfaces@1.1.34-dev-1644230658795: + version "1.1.34-dev-1644230658795" + resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.1.34-dev-1644230658795.tgz#b719329abe31a337ad4350161e8b45f3dd8af1e7" + integrity sha512-lKd5QeZi/kEU9yte5HwUafRLWO0F3L2ArgrNkNoGOgA49tOKE5qS+BYqH/hImmGBSducT3ig8Bd2bCn96iEbDA== dependencies: - medusa-core-utils "1.1.31-dev-1643977380526" + medusa-core-utils "1.1.31-dev-1644230658795" -medusa-telemetry@0.0.11-dev-1643977380526: - version "0.0.11-dev-1643977380526" - resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1643977380526.tgz#becabd4b43192025ae35923f82cba55b2a4ec7e6" - integrity sha512-WhM5e3VjnpKAh2xBk/L0ZzkbV7B5sJHsl8ftHfZdncz1lMnVVBpScatXVBVIVVcwZPvyMD8wj6iMLBhDIYflMg== +medusa-telemetry@0.0.11-dev-1644230658795: + version "0.0.11-dev-1644230658795" + resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1644230658795.tgz#2599eefe6440795e73e71a90ca41e63e61101405" + integrity sha512-yAi6W7NXqVnCvseow5eLhP8mRh8sV79PGN/otPSmhuT3v82W9aXhIdhdOQVIbxsc7N1P0+3iunh7Qu7uR9cElw== dependencies: axios "^0.21.1" axios-retry "^3.1.9" @@ -5165,13 +5165,13 @@ medusa-telemetry@0.0.11-dev-1643977380526: remove-trailing-slash "^0.1.1" uuid "^8.3.2" -medusa-test-utils@1.1.37-dev-1643977380526: - version "1.1.37-dev-1643977380526" - resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1643977380526.tgz#e211bf227b344d079595a091cd4079107b04c98b" - integrity sha512-1BM1GVFcFUVfRq6D5kwXMlpdeXaXRB9Zjl6xppCgFrgooxvoVoT4cKWPMAgyo/Q7JEvOaIaZDT1LKrtFo/QFOg== +medusa-test-utils@1.1.37-dev-1644230658795: + version "1.1.37-dev-1644230658795" + resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1644230658795.tgz#803fcd6b6e7e831a14449af8545edfbe2302af3f" + integrity sha512-VTTuHRngkoGCTLNzTE1Z14BfesRRlpmceS5qnicVkPLidY+l20WhJDAWLpE7RmRNQxuOXirJEdHT99cNQrA0yA== dependencies: "@babel/plugin-transform-classes" "^7.9.5" - medusa-core-utils "1.1.31-dev-1643977380526" + medusa-core-utils "1.1.31-dev-1644230658795" randomatic "^3.1.1" merge-descriptors@1.0.1: diff --git a/packages/medusa/src/api/routes/admin/collections/__tests__/add-products.js b/packages/medusa/src/api/routes/admin/collections/__tests__/add-products.js new file mode 100644 index 0000000000..b576af071b --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/__tests__/add-products.js @@ -0,0 +1,91 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { ProductCollectionServiceMock } from "../../../../../services/__mocks__/product-collection" + +describe("POST /admin/collections/:id/products/batch", () => { + describe("successfully adds products to collection", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/collections/${IdMap.getId("col")}/products/batch`, + { + payload: { + product_ids: ["prod_1", "prod_2"], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("returns updated collection with new products", () => { + expect(subject.body.collection.id).toEqual(IdMap.getId("col")) + }) + + it("product collection service update", () => { + expect(ProductCollectionServiceMock.addProducts).toHaveBeenCalledTimes(1) + expect( + ProductCollectionServiceMock.addProducts + ).toHaveBeenCalledWith(IdMap.getId("col"), ["prod_1", "prod_2"]) + }) + }) + + describe("error on non-existing collection", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/collections/null/products/batch`, + { + payload: { + product_ids: ["prod_1", "prod_2"], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("throws error", () => { + expect(subject.body.message).toBe("Product collection not found") + }) + }) + + describe("error invalid request", () => { + let subject + + beforeAll(async () => { + subject = await request( + "POST", + `/admin/collections/${IdMap.getId("col")}/products/batch`, + { + payload: { + product_ids: [], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/collections/__tests__/remove-products.js b/packages/medusa/src/api/routes/admin/collections/__tests__/remove-products.js new file mode 100644 index 0000000000..9e4e76fb3e --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/__tests__/remove-products.js @@ -0,0 +1,64 @@ +import { IdMap } from "medusa-test-utils" +import { request } from "../../../../../helpers/test-request" +import { ProductCollectionServiceMock } from "../../../../../services/__mocks__/product-collection" + +describe("DELETE /admin/collections/:id/products/batch", () => { + describe("successfully removes products from collection", () => { + let subject + + beforeAll(async () => { + subject = await request( + "DELETE", + `/admin/collections/${IdMap.getId("col")}/products/batch`, + { + payload: { + product_ids: ["prod_1", "prod_2"], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 200", () => { + expect(subject.status).toEqual(200) + }) + + it("product collection service remove products", () => { + expect(ProductCollectionServiceMock.removeProducts).toHaveBeenCalledTimes( + 1 + ) + expect( + ProductCollectionServiceMock.removeProducts + ).toHaveBeenCalledWith(IdMap.getId("col"), ["prod_1", "prod_2"]) + }) + }) + + describe("error on invalid request", () => { + let subject + + beforeAll(async () => { + subject = await request( + "DELETE", + `/admin/collections/${IdMap.getId("col")}/products/batch`, + { + payload: { + product_ids: [], + }, + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + } + ) + }) + + it("returns 400", () => { + expect(subject.status).toEqual(400) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/collections/add-products.ts b/packages/medusa/src/api/routes/admin/collections/add-products.ts new file mode 100644 index 0000000000..15f8171f5b --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/add-products.ts @@ -0,0 +1,52 @@ +import { ArrayNotEmpty, IsString } from "class-validator" +import ProductCollectionService from "../../../../services/product-collection" +import { validator } from "../../../../utils/validator" +/** + * @oas [post] /collections/{id}/products/batch + * operationId: "PostProductsToCollection" + * summary: "Updates products associated with a Product Collection" + * description: "Updates products associated with a Product Collection" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Collection. + * requestBody: + * content: + * application/json: + * schema: + * properties: + * product_ids: + * description: "An array of Product IDs to add to the Product Collection." + * type: array + * items: + * properties: + * id: + * description: "The ID of a Product to add to the Product Collection." + * type: string + * tags: + * - Collection + * responses: + * "200": + * description: OK + */ +export default async (req, res) => { + const { id } = req.params + + const validated = await validator(AdminPostProductsToCollectionReq, req.body) + + const productCollectionService: ProductCollectionService = req.scope.resolve( + "productCollectionService" + ) + + const collection = await productCollectionService.addProducts( + id, + validated.product_ids + ) + + res.status(200).json({ collection }) +} + +export class AdminPostProductsToCollectionReq { + @ArrayNotEmpty() + @IsString({ each: true }) + product_ids: string[] +} diff --git a/packages/medusa/src/api/routes/admin/collections/index.ts b/packages/medusa/src/api/routes/admin/collections/index.ts index ba98fa8606..cbd5239e5b 100644 --- a/packages/medusa/src/api/routes/admin/collections/index.ts +++ b/packages/medusa/src/api/routes/admin/collections/index.ts @@ -17,6 +17,9 @@ export default (app) => { route.get("/:id", middlewares.wrap(require("./get-collection").default)) route.get("/", middlewares.wrap(require("./list-collections").default)) + route.post("/:id/products/batch", middlewares.wrap(require("./add-products").default)) + route.delete("/:id/products/batch", middlewares.wrap(require("./remove-products").default)) + return app } diff --git a/packages/medusa/src/api/routes/admin/collections/remove-products.ts b/packages/medusa/src/api/routes/admin/collections/remove-products.ts new file mode 100644 index 0000000000..54b9d7f4af --- /dev/null +++ b/packages/medusa/src/api/routes/admin/collections/remove-products.ts @@ -0,0 +1,56 @@ +import { ArrayNotEmpty, IsString } from "class-validator" +import ProductCollectionService from "../../../../services/product-collection" +import { validator } from "../../../../utils/validator" +/** + * @oas [delete] /collections/{id}/products/batch + * operationId: "DeleteProductsFromCollection" + * summary: "Removes products associated with a Product Collection" + * description: "Removes products associated with a Product Collection" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Collection. + * requestBody: + * content: + * application/json: + * schema: + * properties: + * product_ids: + * description: "An array of Product IDs to remove from the Product Collection." + * type: array + * items: + * properties: + * id: + * description: "The ID of a Product to remove from the Product Collection." + * type: string + * tags: + * - Collection + * responses: + * "200": + * description: OK + */ +export default async (req, res) => { + const { id } = req.params + + const validated = await validator( + AdminDeleteProductsFromCollectionReq, + req.body + ) + + const productCollectionService: ProductCollectionService = req.scope.resolve( + "productCollectionService" + ) + + await productCollectionService.removeProducts(id, validated.product_ids) + + res.json({ + id, + object: "product-collection", + removed_products: validated.product_ids, + }) +} + +export class AdminDeleteProductsFromCollectionReq { + @ArrayNotEmpty() + @IsString({ each: true }) + product_ids: string[] +} diff --git a/packages/medusa/src/models/product.ts b/packages/medusa/src/models/product.ts index dd77f8e308..93eb17e8e5 100644 --- a/packages/medusa/src/models/product.ts +++ b/packages/medusa/src/models/product.ts @@ -1,22 +1,21 @@ +import _ from "lodash" import { - Entity, - Index, BeforeInsert, Column, - DeleteDateColumn, CreateDateColumn, - UpdateDateColumn, - PrimaryColumn, - OneToOne, - OneToMany, - ManyToOne, - ManyToMany, + DeleteDateColumn, + Entity, + Index, JoinColumn, JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryColumn, + UpdateDateColumn, } from "typeorm" import { ulid } from "ulid" -import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column" - +import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column" import { Image } from "./image" import { ProductCollection } from "./product-collection" import { ProductOption } from "./product-option" @@ -24,7 +23,6 @@ import { ProductTag } from "./product-tag" import { ProductType } from "./product-type" import { ProductVariant } from "./product-variant" import { ShippingProfile } from "./shipping-profile" -import _ from "lodash" export enum Status { DRAFT = "draft", @@ -76,13 +74,13 @@ export class Product { @OneToMany( () => ProductOption, - productOption => productOption.product + (productOption) => productOption.product ) options: ProductOption[] @OneToMany( () => ProductVariant, - variant => variant.product, + (variant) => variant.product, { cascade: true } ) variants: ProductVariant[] @@ -120,7 +118,7 @@ export class Product { material: string @Column({ nullable: true }) - collection_id: string + collection_id: string | null @ManyToOne(() => ProductCollection) @JoinColumn({ name: "collection_id" }) diff --git a/packages/medusa/src/repositories/product.ts b/packages/medusa/src/repositories/product.ts index 831c6f17e4..8926ab5d11 100644 --- a/packages/medusa/src/repositories/product.ts +++ b/packages/medusa/src/repositories/product.ts @@ -3,6 +3,7 @@ import { EntityRepository, FindManyOptions, FindOperator, + In, OrderByCondition, Repository, } from "typeorm" @@ -76,7 +77,9 @@ export class ProductRepository extends Repository { return [entities, count] } - private getGroupedRelations(relations: Array): { + private getGroupedRelations( + relations: Array + ): { [toplevel: string]: string[] } { const groupedRelations: { [toplevel: string]: string[] } = {} @@ -194,8 +197,9 @@ export class ProductRepository extends Repository { ) const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) - const entitiesToReturn = - this.mergeEntitiesWithRelations(entitiesAndRelations) + const entitiesToReturn = this.mergeEntitiesWithRelations( + entitiesAndRelations + ) return [entitiesToReturn, count] } @@ -236,8 +240,9 @@ export class ProductRepository extends Repository { ) const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) - const entitiesToReturn = - this.mergeEntitiesWithRelations(entitiesAndRelations) + const entitiesToReturn = this.mergeEntitiesWithRelations( + entitiesAndRelations + ) return entitiesToReturn } @@ -255,4 +260,30 @@ export class ProductRepository extends Repository { ) return result[0] } + + public async bulkAddToCollection( + productIds: string[], + collectionId: string + ): Promise { + await this.createQueryBuilder() + .update(Product) + .set({ collection_id: collectionId }) + .where({ id: In(productIds) }) + .execute() + + return this.findByIds(productIds) + } + + public async bulkRemoveFromCollection( + productIds: string[], + collectionId: string + ): Promise { + await this.createQueryBuilder() + .update(Product) + .set({ collection_id: null }) + .where({ id: In(productIds), collection_id: collectionId }) + .execute() + + return this.findByIds(productIds) + } } diff --git a/packages/medusa/src/services/__mocks__/product-collection.js b/packages/medusa/src/services/__mocks__/product-collection.js index caf198e96e..1eff830d7d 100644 --- a/packages/medusa/src/services/__mocks__/product-collection.js +++ b/packages/medusa/src/services/__mocks__/product-collection.js @@ -1,7 +1,7 @@ import { IdMap } from "medusa-test-utils" export const ProductCollectionServiceMock = { - withTransaction: function () { + withTransaction: function() { return this }, create: jest.fn().mockImplementation((data) => { @@ -16,6 +16,16 @@ export const ProductCollectionServiceMock = { update: jest.fn().mockImplementation((id, value) => { return Promise.resolve({ id, title: value }) }), + addProducts: jest.fn().mockImplementation((id, product_ids) => { + if (id === IdMap.getId("col")) { + return Promise.resolve({ + id, + products: product_ids.map((i) => ({ id: i })), + }) + } + throw new Error("Product collection not found") + }), + removeProducts: jest.fn().mockReturnValue(Promise.resolve()), list: jest.fn().mockImplementation((data) => { return Promise.resolve([{ id: IdMap.getId("col"), title: "Suits" }]) }), diff --git a/packages/medusa/src/services/product-collection.js b/packages/medusa/src/services/product-collection.js index 35748f8c62..faf8d2a01e 100644 --- a/packages/medusa/src/services/product-collection.js +++ b/packages/medusa/src/services/product-collection.js @@ -162,6 +162,32 @@ class ProductCollectionService extends BaseService { }) } + async addProducts(collectionId, productIds) { + return this.atomicPhase_(async (manager) => { + const productRepo = manager.getCustomRepository(this.productRepository_) + + const { id } = await this.retrieve(collectionId, { select: ["id"] }) + + await productRepo.bulkAddToCollection(productIds, id) + + return await this.retrieve(id, { + relations: ["products"], + }) + }) + } + + async removeProducts(collectionId, productIds) { + return this.atomicPhase_(async (manager) => { + const productRepo = manager.getCustomRepository(this.productRepository_) + + const { id } = await this.retrieve(collectionId, { select: ["id"] }) + + await productRepo.bulkRemoveFromCollection(productIds, id) + + return Promise.resolve() + }) + } + /** * Lists product collections * @param {Object} selector - the query object for find diff --git a/packages/medusa/src/services/swap.js b/packages/medusa/src/services/swap.js index a8bbf8240d..15050d76ff 100644 --- a/packages/medusa/src/services/swap.js +++ b/packages/medusa/src/services/swap.js @@ -1,5 +1,5 @@ -import { BaseService } from "medusa-interfaces" import { MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" /** * Handles swaps