diff --git a/integration-tests/api/__tests__/admin/price-list.js b/integration-tests/api/__tests__/admin/price-list.js index 0c03141bf2..b1c39b9819 100644 --- a/integration-tests/api/__tests__/admin/price-list.js +++ b/integration-tests/api/__tests__/admin/price-list.js @@ -211,6 +211,117 @@ describe("/admin/price-lists", () => { ]) ) }) + + it("given a search query, returns matching results by name", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=winter", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "VIP winter sale", + }), + ]) + ) + expect(response.data.count).toEqual(1) + }) + + it("given a search query, returns matching results by description", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=25%", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "VIP winter sale", + description: + "Winter sale for VIP customers. 25% off selected items.", + }), + ]) + ) + expect(response.data.count).toEqual(1) + }) + + it("given a search query, returns empty list when does not exist", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=blablabla", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual([]) + expect(response.data.count).toEqual(0) + }) + + it("given a search query and a status filter not matching any price list, returns an empty set", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=vip&status[]=draft", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual([]) + expect(response.data.count).toEqual(0) + }) + + it("given a search query and a status filter matching a price list, returns a price list", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=vip&status[]=active", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "VIP winter sale", + status: "active", + }), + ]) + ) + expect(response.data.count).toEqual(1) + }) }) describe("POST /admin/price-lists/:id", () => { @@ -794,9 +905,17 @@ describe("/admin/price-lists", () => { await simplePriceListFactory(dbConnection, { id: "test-list", + customer_groups: ["test-group"], prices: [ - { variant_id: "test-variant-1", currency_code: "usd", amount: 100 }, - { variant_id: "test-variant-4", currency_code: "usd", amount: 100 }, + { variant_id: "test-variant-1", currency_code: "usd", amount: 150 }, + { variant_id: "test-variant-4", currency_code: "usd", amount: 150 }, + ], + }) + await simplePriceListFactory(dbConnection, { + id: "test-list-2", + prices: [ + { variant_id: "test-variant-1", currency_code: "usd", amount: 200 }, + { variant_id: "test-variant-4", currency_code: "usd", amount: 200 }, ], }) } catch (err) { @@ -810,7 +929,7 @@ describe("/admin/price-lists", () => { await db.teardown() }) - it("lists only product 1, 2", async () => { + it("lists only product 1, 2 with price list prices", async () => { const api = useApi() const response = await api @@ -826,8 +945,50 @@ describe("/admin/price-lists", () => { expect(response.status).toEqual(200) expect(response.data.count).toEqual(2) expect(response.data.products).toEqual([ - expect.objectContaining({ id: "test-prod-1" }), - expect.objectContaining({ id: "test-prod-2" }), + expect.objectContaining({ + id: "test-prod-1", + variants: [ + expect.objectContaining({ + id: "test-variant-1", + prices: [ + expect.objectContaining({ currency_code: "usd", amount: 100 }), + expect.objectContaining({ + currency_code: "usd", + amount: 150, + price_list_id: "test-list", + }), + ], + }), + expect.objectContaining({ + id: "test-variant-2", + prices: [ + expect.objectContaining({ currency_code: "usd", amount: 100 }), + ], + }), + ], + }), + expect.objectContaining({ + id: "test-prod-2", + variants: [ + expect.objectContaining({ + id: "test-variant-3", + prices: [ + expect.objectContaining({ currency_code: "usd", amount: 100 }), + ], + }), + expect.objectContaining({ + id: "test-variant-4", + prices: [ + expect.objectContaining({ currency_code: "usd", amount: 100 }), + expect.objectContaining({ + currency_code: "usd", + amount: 150, + price_list_id: "test-list", + }), + ], + }), + ], + }), ]) }) diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index df71a7530f..831a12296d 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -6,7 +6,7 @@ const { initDb, useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") const productSeeder = require("../../helpers/product-seeder") -const { ProductVariant } = require("@medusajs/medusa") +const { ProductVariant, ProductOptionValue, MoneyAmount } = require("@medusajs/medusa") const priceListSeeder = require("../../helpers/price-list-seeder") jest.setTimeout(50000) @@ -1737,6 +1737,177 @@ describe("/admin/products", () => { expect(variant).toEqual(undefined) }) + it("successfully deletes a product variant and its associated option values", async () => { + const api = useApi() + + // Validate that the option value exists + const optValPre = await dbConnection.manager.findOne(ProductOptionValue, { + variant_id: "test-variant_2", + }) + + expect(optValPre).not.toEqual(undefined) + + // Soft delete the variant + const response = await api.delete( + "/admin/products/test-product/variants/test-variant_2", + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + + expect(response.status).toEqual(200) + + // Validate that the option value was deleted + const optValPost = await dbConnection.manager.findOne( + ProductOptionValue, + { + variant_id: "test-variant_2", + } + ) + + expect(optValPost).toEqual(undefined) + + // Validate that the option still exists in the DB with deleted_at + const optValDeleted = await dbConnection.manager.findOne(ProductOptionValue, { + variant_id: "test-variant_2", + }, { + withDeleted: true, + }) + + expect(optValDeleted).toEqual(expect.objectContaining({ + deleted_at: expect.any(Date), + variant_id: "test-variant_2", + })) + }) + + it("successfully deletes a product and any option value associated with one of its variants", async () => { + const api = useApi() + + // Validate that the option value exists + const optValPre = await dbConnection.manager.findOne(ProductOptionValue, { + variant_id: "test-variant_2", + }) + + expect(optValPre).not.toEqual(undefined) + + // Soft delete the product + const response = await api.delete("/admin/products/test-product", { + headers: { + Authorization: "Bearer test_token", + }, + }) + + expect(response.status).toEqual(200) + + // Validate that the option value has been deleted + const optValPost = await dbConnection.manager.findOne( + ProductOptionValue, + { + variant_id: "test-variant_2", + } + ) + + expect(optValPost).toEqual(undefined) + + // Validate that the option still exists in the DB with deleted_at + const optValDeleted = await dbConnection.manager.findOne(ProductOptionValue, { + variant_id: "test-variant_2", + }, { + withDeleted: true, + }) + + expect(optValDeleted).toEqual(expect.objectContaining({ + deleted_at: expect.any(Date), + variant_id: "test-variant_2", + })) + }) + + it("successfully deletes a product variant and its associated prices", async () => { + const api = useApi() + + // Validate that the price exists + const pricePre = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }) + + expect(pricePre).not.toEqual(undefined) + + // Soft delete the variant + const response = await api.delete( + "/admin/products/test-product/variants/test-variant", + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + + expect(response.status).toEqual(200) + + // Validate that the price was deleted + const pricePost = await dbConnection.manager.findOne( + MoneyAmount, + { + id: "test-price", + } + ) + + expect(pricePost).toEqual(undefined) + + // Validate that the price still exists in the DB with deleted_at + const optValDeleted = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }, { + withDeleted: true, + }) + + expect(optValDeleted).toEqual(expect.objectContaining({ + deleted_at: expect.any(Date), + id: "test-price", + })) + }) + + it("successfully deletes a product and any prices associated with one of its variants", async () => { + const api = useApi() + + // Validate that the price exists + const pricePre = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }) + + expect(pricePre).not.toEqual(undefined) + + // Soft delete the product + const response = await api.delete("/admin/products/test-product", { + headers: { + Authorization: "Bearer test_token", + }, + }) + + expect(response.status).toEqual(200) + + // Validate that the price has been deleted + const pricePost = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }) + + expect(pricePost).toEqual(undefined) + + // Validate that the price still exists in the DB with deleted_at + const optValDeleted = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }, { + withDeleted: true, + }) + + expect(optValDeleted).toEqual(expect.objectContaining({ + deleted_at: expect.any(Date), + id: "test-price", + })) + }) + it("successfully creates product with soft-deleted product handle and deletes it again", async () => { const api = useApi() diff --git a/integration-tests/api/factories/simple-price-list-factory.ts b/integration-tests/api/factories/simple-price-list-factory.ts index 439b35deec..a20380d3b0 100644 --- a/integration-tests/api/factories/simple-price-list-factory.ts +++ b/integration-tests/api/factories/simple-price-list-factory.ts @@ -3,6 +3,7 @@ import { MoneyAmount, PriceListType, PriceListStatus, + CustomerGroup, } from "@medusajs/medusa" import faker from "faker" import { Connection } from "typeorm" @@ -38,6 +39,28 @@ export const simplePriceListFactory = async ( const manager = connection.manager const listId = data.id || `simple-price-list-${Math.random() * 1000}` + + let customerGroups = [] + if (typeof data.customer_groups !== "undefined") { + await manager + .createQueryBuilder() + .insert() + .into(CustomerGroup) + .values( + data.customer_groups.map((group) => ({ + id: group, + name: faker.company.companyName(), + })) + ) + .orIgnore() + .execute() + + customerGroups = await manager.findByIds( + CustomerGroup, + data.customer_groups + ) + } + const toCreate = { id: listId, name: data.name || faker.commerce.productName(), @@ -46,6 +69,7 @@ export const simplePriceListFactory = async ( type: data.type || PriceListType.OVERRIDE, starts_at: data.starts_at || null, ends_at: data.ends_at || null, + customer_groups: customerGroups, } const toSave = manager.create(PriceList, toCreate) diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index 42e6e05c96..40318b328f 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -8,16 +8,16 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { - "@medusajs/medusa": "1.2.1-dev-1649181615374", + "@medusajs/medusa": "1.2.1-dev-1650573289860", "faker": "^5.5.3", - "medusa-interfaces": "1.2.1-dev-1649181615374", + "medusa-interfaces": "1.2.1-dev-1650573289860", "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-1649181615374", + "babel-preset-medusa-package": "1.1.19-dev-1650573289860", "jest": "^26.6.3" } } diff --git a/integration-tests/api/yarn.lock b/integration-tests/api/yarn.lock index b81b1094d6..b83de4e288 100644 --- a/integration-tests/api/yarn.lock +++ b/integration-tests/api/yarn.lock @@ -1301,10 +1301,10 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@medusajs/medusa-cli@1.2.1-dev-1649181615374": - version "1.2.1-dev-1649181615374" - resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.1-dev-1649181615374.tgz#1ea9014e3ec9813a52457b0d6e2fc6bb64d3bfd6" - integrity sha512-8m6Z1ZZqstZKaAaKoFS3v3IzI7BFhcBgpF+iCSRuJoXltQgzVQOAxXuPjkRoi+m1ZZ+Yi/YYEzKmNQ99vmXisQ== +"@medusajs/medusa-cli@1.2.1-dev-1650573289860": + version "1.2.1-dev-1650573289860" + resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.1-dev-1650573289860.tgz#7685e4add2985e95fd945f6e7154f6ecb175d565" + integrity sha512-RpLR/uM/HfEEFtlZmeImT295ohpSCOKTrWcKVXj8UT6L4jj+FJ0SpcW3Fnr2Q5kOulQVL3qXx5t79Nz02O4qvw== dependencies: "@babel/polyfill" "^7.8.7" "@babel/runtime" "^7.9.6" @@ -1322,8 +1322,8 @@ is-valid-path "^0.1.1" joi-objectid "^3.0.1" meant "^1.0.1" - medusa-core-utils "1.1.31-dev-1649181615374" - medusa-telemetry "0.0.11-dev-1649181615374" + medusa-core-utils "1.1.31-dev-1650573289860" + medusa-telemetry "0.0.11-dev-1650573289860" netrc-parser "^3.1.6" open "^8.0.6" ora "^5.4.1" @@ -1337,13 +1337,13 @@ winston "^3.3.3" yargs "^15.3.1" -"@medusajs/medusa@1.2.1-dev-1649181615374": - version "1.2.1-dev-1649181615374" - resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.1-dev-1649181615374.tgz#6a62f8628b84b47a8717e9e0c276f3a9c2e376ce" - integrity sha512-eiCGE6JqYuP7GCzTBGg5LI9U0uQ0wlsR+NuMZVEwldj+xc7qwMjBJwUA7gc58gBv6JesfMYj3VZmJComN4+7Bg== +"@medusajs/medusa@1.2.1-dev-1650573289860": + version "1.2.1-dev-1650573289860" + resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.1-dev-1650573289860.tgz#740d20bf349be9ad9e0f151305a75bd40284b641" + integrity sha512-kU3l95SjU/B4SQLhof2obdXbWwkMcRKDnSXE2CB4G1a9jchwnzfRDVDy2MjO/4/0l6AgTLHwxoBR8F9zYUsiDQ== dependencies: "@hapi/joi" "^16.1.8" - "@medusajs/medusa-cli" "1.2.1-dev-1649181615374" + "@medusajs/medusa-cli" "1.2.1-dev-1650573289860" "@types/lodash" "^4.14.168" awilix "^4.2.3" body-parser "^1.19.0" @@ -1356,7 +1356,6 @@ core-js "^3.6.5" cors "^2.8.5" cross-spawn "^7.0.3" - dotenv "^8.2.0" express "^4.17.1" express-session "^1.17.1" fs-exists-cached "^1.0.0" @@ -1367,8 +1366,8 @@ joi "^17.3.0" joi-objectid "^3.0.1" jsonwebtoken "^8.5.1" - medusa-core-utils "1.1.31-dev-1649181615374" - medusa-test-utils "1.1.37-dev-1649181615374" + medusa-core-utils "1.1.31-dev-1650573289860" + medusa-test-utils "1.1.37-dev-1650573289860" morgan "^1.9.1" multer "^1.4.2" passport "^0.4.0" @@ -2010,10 +2009,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-1649181615374: - version "1.1.19-dev-1649181615374" - resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1649181615374.tgz#2f13d52fedd336ad4b4c0602b3bf4696d2d08db7" - integrity sha512-N4XL7rTmNM2W+iRR92xvU4bKadP25lY5QR3vndxTxsLNSgcR5tLjKLO/4j7AqiFvcthbE8cF1TcdECH5aJfSuA== +babel-preset-medusa-package@1.1.19-dev-1650573289860: + version "1.1.19-dev-1650573289860" + resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1650573289860.tgz#493490de8ca1ce75b30545f19fdb9544b6324af4" + integrity sha512-++eqULlSbdH4bnwi/edLa097io4sxZvJSaXIwSC0it7GDB3IXITk5xyuxgKCJue3psSexwuUV58i/or8sZ1idg== dependencies: "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-decorators" "^7.12.1" @@ -5156,25 +5155,23 @@ 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-1649181615374: - version "1.1.31-dev-1649181615374" - resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1649181615374.tgz#60416bba53eaba607d77ca36789aa23756b8db0f" - integrity sha512-w5nusocZweIrAFJ6sl4hD/mN+UtNjz39IIfXukekyJByg3wpv4P+vsW3XdrFTX5OLvKVxuzjl3B2zeZUmdgSKg== +medusa-core-utils@1.1.31-dev-1650573289860: + version "1.1.31-dev-1650573289860" + resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1650573289860.tgz#4b6ce1ab888a1b56dc08657e1c9ec0686678d7c1" + integrity sha512-y4Xy9Z+LQAXK4CzGzrC+sn0ngTfZgzIbVTUFVi2YhjRrjCXP36Caisf7e5gEkvmC8TxFsl061mneTcZBX+ni9g== dependencies: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-interfaces@1.2.1-dev-1649181615374: - version "1.2.1-dev-1649181615374" - resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.1-dev-1649181615374.tgz#0b664f4e3e8e61b67108a41c8f0f9dd58a947075" - integrity sha512-JRD773nZnxjn/2oNrgb/zXn+scBoNHpW97YQYW7+LFX1JBYafzyGQW3vWTFX4X+q08ehz4dc21CgoMeYco8yvQ== - dependencies: - medusa-core-utils "1.1.31-dev-1649181615374" +medusa-interfaces@1.2.1-dev-1650573289860: + version "1.2.1-dev-1650573289860" + resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.1-dev-1650573289860.tgz#088ef6571cf3ec4b77de716bb6a3897f5a7a3de7" + integrity sha512-/WFMXz6iZp8tau6V/eVYao4SoIyYDrIUKXx32dfFibsQdnf8ev2CL08iTncfmWgAdlHNlO3lMJKF4arEdv3QTQ== -medusa-telemetry@0.0.11-dev-1649181615374: - version "0.0.11-dev-1649181615374" - resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1649181615374.tgz#3f4c366ea8d0d0fdde9b289f7e771bb27adae56d" - integrity sha512-RMJR3/qlTb1nV05RnBnX1bNOvYyeuXf4owxLlfbWzKAZirWQ5LAC2GikEGGbHGbw7UgiLgQtu4Rnmg2Uye+VcA== +medusa-telemetry@0.0.11-dev-1650573289860: + version "0.0.11-dev-1650573289860" + resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1650573289860.tgz#d74c00da87adc4e0105047db519dcbbddc36475e" + integrity sha512-UFOGj3hKpfJLKIaQMZQqb2DlGs0gScPJdrgMa5GQFwOGxcYCN2D6gyWtIWuKEDZ8oG4X8MCE0zTa3r+Sh4+zPQ== dependencies: axios "^0.21.1" axios-retry "^3.1.9" @@ -5186,13 +5183,13 @@ medusa-telemetry@0.0.11-dev-1649181615374: remove-trailing-slash "^0.1.1" uuid "^8.3.2" -medusa-test-utils@1.1.37-dev-1649181615374: - version "1.1.37-dev-1649181615374" - resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1649181615374.tgz#079c16a791d47c52072c6f0837d0a827208bc9cc" - integrity sha512-hj3iNZsIA01l7qAZrOgt+kT8PDkXKoW4CEL3bhVfIUEwsdv9jID7FGdTIN/7G3diioTypvrVcqRrp0uiWdgp+Q== +medusa-test-utils@1.1.37-dev-1650573289860: + version "1.1.37-dev-1650573289860" + resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1650573289860.tgz#1c8705617b64c4a474891f99985044faa5f8fa2f" + integrity sha512-MnKhy7hbNcZdYjVm9B3Z9MAT7MTf4oTdqQR9/lmb4qd7dJqd2AbxhglsOn5ZRYQHynuzubS+j9EHkxgOgji8MQ== dependencies: "@babel/plugin-transform-classes" "^7.9.5" - medusa-core-utils "1.1.31-dev-1649181615374" + medusa-core-utils "1.1.31-dev-1650573289860" randomatic "^3.1.1" merge-descriptors@1.0.1: diff --git a/packages/medusa-file-spaces/src/services/digital-ocean.js b/packages/medusa-file-spaces/src/services/digital-ocean.js index 82e6a66e77..d9ef58e2e7 100644 --- a/packages/medusa-file-spaces/src/services/digital-ocean.js +++ b/packages/medusa-file-spaces/src/services/digital-ocean.js @@ -1,5 +1,6 @@ import fs from "fs" import aws from "aws-sdk" +import { parse } from "path" import { FileService } from "medusa-interfaces" class DigitalOceanService extends FileService { @@ -23,12 +24,14 @@ class DigitalOceanService extends FileService { endpoint: this.endpoint_, }) + const parsedFilename = parse(file.originalname) + const fileKey = `${parsedFilename.name}-${Date.now()}${parsedFilename.ext}` const s3 = new aws.S3() var params = { ACL: "public-read", Bucket: this.bucket_, Body: fs.createReadStream(file.path), - Key: `${file.originalname}`, + Key: fileKey, } return new Promise((resolve, reject) => { diff --git a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts index 5cbe0abba6..21a3f73b66 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts @@ -1,5 +1,5 @@ import { Type } from "class-transformer" -import { omit } from "lodash" +import { omit, pickBy } from "lodash" import { IsArray, IsBoolean, @@ -19,6 +19,9 @@ import { defaultAdminProductRelations, } from "../products" import listAndCount from "../../../../controllers/products/admin-list-products" +import { MedusaError } from "medusa-core-utils" +import { getListConfig } from "../../../../utils/get-query-config" +import PriceListService from "../../../../services/price-list" /** * @oas [get] /price-lists/:id/products @@ -78,7 +81,7 @@ export default async (req, res) => { req.query.price_list_id = [id] - const filterableFields: FilterableProductProps = omit(req.query, [ + const query: FilterableProductProps = omit(req.query, [ "limit", "offset", "expand", @@ -86,23 +89,71 @@ export default async (req, res) => { "order", ]) - const result = await listAndCount( - req.scope, - filterableFields, - {}, - { - limit: validatedParams.limit ?? 50, - offset: validatedParams.offset ?? 0, - expand: validatedParams.expand, - fields: validatedParams.fields, - order: validatedParams.order, - allowedFields: allowedAdminProductFields, - defaultFields: defaultAdminProductFields as (keyof Product)[], - defaultRelations: defaultAdminProductRelations, - } + const limit = validatedParams.limit ?? 50 + const offset = validatedParams.offset ?? 0 + const expand = validatedParams.expand + const fields = validatedParams.fields + const order = validatedParams.order + const allowedFields = allowedAdminProductFields + const defaultFields = defaultAdminProductFields as (keyof Product)[] + const defaultRelations = defaultAdminProductRelations.filter( + (r) => r !== "variants.prices" ) - res.json(result) + const priceListService: PriceListService = + req.scope.resolve("priceListService") + + let includeFields: (keyof Product)[] | undefined + if (fields) { + includeFields = fields.split(",") as (keyof Product)[] + } + + let expandFields: string[] | undefined + if (expand) { + expandFields = expand.split(",") + } + + let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined + if (typeof order !== "undefined") { + let orderField = order + if (order.startsWith("-")) { + const [, field] = order.split("-") + orderField = field + orderBy = { [field]: "DESC" } + } else { + orderBy = { [order]: "ASC" } + } + + if (!(allowedFields || []).includes(orderField)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Order field must be a valid product field" + ) + } + } + + const listConfig = getListConfig( + defaultFields ?? [], + defaultRelations ?? [], + includeFields, + expandFields, + limit, + offset, + orderBy + ) + + const [products, count] = await priceListService.listProducts( + id, + pickBy(query, (val) => typeof val !== "undefined"), + listConfig + ) + + res.json({ + products, + count, + offset, + limit, + }) } enum ProductStatus { diff --git a/packages/medusa/src/interfaces/__tests__/base-service.spec.ts b/packages/medusa/src/interfaces/__tests__/transaction-base-service.spec.ts similarity index 56% rename from packages/medusa/src/interfaces/__tests__/base-service.spec.ts rename to packages/medusa/src/interfaces/__tests__/transaction-base-service.spec.ts index 9166759fac..65c5b62608 100644 --- a/packages/medusa/src/interfaces/__tests__/base-service.spec.ts +++ b/packages/medusa/src/interfaces/__tests__/transaction-base-service.spec.ts @@ -1,12 +1,15 @@ -import BaseService from "../base-service" -import { In, Not } from "typeorm" +import { EntityManager } from "typeorm" import { MockManager } from "medusa-test-utils" +import { TransactionBaseService } from "../transaction-base-service" -describe("BaseService", () => { +describe("TransactionBaseService", () => { it("should cloned the child class withTransaction", () => { - class Child extends BaseService { + class Child extends TransactionBaseService { + protected manager_!: EntityManager + protected transactionManager_!: EntityManager + constructor(protected readonly container) { - super(container, {}); + super(container); this.container = container } @@ -32,30 +35,4 @@ describe("BaseService", () => { expect(child2.getTransactionManager()).toBeTruthy() expect((child2.getTransactionManager() as any)?.testProp).toBe('testProp') }) - - describe("buildQuery_", () => { - const baseService = new BaseService({}, {}) - - it("successfully creates query", () => { - const q = baseService.buildQuery_( - { - id: "1234", - test1: ["123", "12", "1"], - test2: Not("this"), - }, - { - relations: ["1234"], - } - ) - - expect(q).toEqual({ - where: { - id: "1234", - test1: In(["123", "12", "1"]), - test2: Not("this"), - }, - relations: ["1234"], - }) - }) - }) -}) +}) \ No newline at end of file diff --git a/packages/medusa/src/interfaces/base-service.ts b/packages/medusa/src/interfaces/base-service.ts deleted file mode 100644 index 18560465f2..0000000000 --- a/packages/medusa/src/interfaces/base-service.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { MedusaError } from "medusa-core-utils" -import { EntityManager, FindOperator, In, Raw } from "typeorm" -import { IsolationLevel } from "typeorm/driver/types/IsolationLevel" -import { FindConfig } from "../types/common" - -type Selector = { [key in keyof TEntity]?: unknown } - -/** - * Common functionality for Services - * @interface - */ -class BaseService< - TChild extends BaseService, - TContainer = unknown -> { - protected transactionManager_: EntityManager | undefined - protected manager_: EntityManager - private readonly container_: TContainer - - constructor( - container: TContainer, - protected readonly configModule: Record - ) { - this.container_ = container - } - - withTransaction(): this - withTransaction(transactionManager: EntityManager): TChild - withTransaction(transactionManager?: EntityManager): this | TChild { - if (!transactionManager) { - return this - } - - const cloned = new (this.constructor)< - TChild, - TContainer - >( - { - ...this.container_, - manager: transactionManager, - }, - this.configModule - ) - - cloned.transactionManager_ = transactionManager - - return cloned as TChild - } - - /** - * Used to build TypeORM queries. - * @param selector The selector - * @param config The config - * @return The QueryBuilderConfig - */ - buildQuery_( - selector: Selector, - config: FindConfig = {} - ): FindConfig & { - where: { [key in keyof TEntity]?: unknown } - withDeleted?: boolean - } { - const build = ( - obj: Record - ): { [key in keyof TEntity]?: unknown } => { - return Object.entries(obj).reduce((acc, [key, value]: any) => { - // Undefined values indicate that they have no significance to the query. - // If the query is looking for rows where a column is not set it should use null instead of undefined - if (typeof value === "undefined") { - return acc - } - - const subquery: { - operator: "<" | ">" | "<=" | ">=" - value: unknown - }[] = [] - - switch (true) { - case value instanceof FindOperator: - acc[key] = value - break - case Array.isArray(value): - acc[key] = In([...(value as unknown[])]) - break - case value !== null && typeof value === "object": - Object.entries(value as Record).map( - ([modifier, val]) => { - switch (modifier) { - case "lt": - subquery.push({ operator: "<", value: val }) - break - case "gt": - subquery.push({ operator: ">", value: val }) - break - case "lte": - subquery.push({ operator: "<=", value: val }) - break - case "gte": - subquery.push({ operator: ">=", value: val }) - break - default: - acc[key] = value - break - } - } - ) - - if (subquery.length) { - acc[key] = Raw( - (a) => - subquery - .map((s, index) => `${a} ${s.operator} :${index}`) - .join(" AND "), - subquery.map((s) => s.value) - ) - } - break - default: - acc[key] = value - break - } - - return acc - }, {} as { [key in keyof TEntity]?: unknown }) - } - - const query: FindConfig & { - where: { [key in keyof TEntity]?: unknown } - withDeleted?: boolean - } = { - where: build(selector), - } - - if ("deleted_at" in selector) { - query.withDeleted = true - } - - if ("skip" in config) { - query.skip = config.skip - } - - if ("take" in config) { - query.take = config.take - } - - if ("relations" in config) { - query.relations = config.relations - } - - if ("select" in config) { - query.select = config.select - } - - if ("order" in config) { - query.order = config.order - } - - return query - } - - /** - * Confirms whether a given raw id is valid. Fails if the provided - * id is null or undefined. The validate function takes an optional config - * param, to support checking id prefix and length. - * @param rawId - the id to validate. - * @param config - optional config - * @returns the rawId given that nothing failed - */ - validateId_( - rawId: string, - config: { prefix?: string; length?: number } = {} - ): string { - const { prefix, length } = config - if (!rawId) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Failed to validate id: ${rawId}` - ) - } - - if (prefix || length) { - const [pre, rand] = rawId.split("_") - if (prefix && pre !== prefix) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `The provided id: ${rawId} does not adhere to prefix constraint: ${prefix}` - ) - } - - if (length && length !== rand.length) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `The provided id: ${rawId} does not adhere to length constraint: ${length}` - ) - } - } - - return rawId - } - - shouldRetryTransaction( - err: { code: string } | Record - ): boolean { - if (!(err as { code: string })?.code) { - return false - } - const code = (err as { code: string })?.code - return code === "40001" || code === "40P01" - } - - /** - * Wraps some work within a transactional block. If the service already has - * a transaction manager attached this will be reused, otherwise a new - * transaction manager is created. - * @param work - the transactional work to be done - * @param isolationOrErrorHandler - the isolation level to be used for the work. - * @param maybeErrorHandlerOrDontFail Potential error handler - * @return the result of the transactional work - */ - async atomicPhase_( - work: (transactionManager: EntityManager) => Promise, - isolationOrErrorHandler?: - | IsolationLevel - | ((error: unknown) => Promise), - maybeErrorHandlerOrDontFail?: (error: unknown) => Promise - ): Promise { - let errorHandler = maybeErrorHandlerOrDontFail - let isolation: - | IsolationLevel - | ((error: unknown) => Promise) - | undefined - | null = isolationOrErrorHandler - let dontFail = false - if (typeof isolationOrErrorHandler === "function") { - isolation = null - errorHandler = isolationOrErrorHandler - dontFail = !!maybeErrorHandlerOrDontFail - } - - if (this.transactionManager_) { - const doWork = async (m: EntityManager): Promise => { - this.manager_ = m - this.transactionManager_ = m - try { - return await work(m) - } catch (error) { - if (errorHandler) { - const queryRunner = this.transactionManager_.queryRunner - if (queryRunner && queryRunner.isTransactionActive) { - await queryRunner.rollbackTransaction() - } - - await errorHandler(error) - } - throw error - } - } - - return doWork(this.transactionManager_) - } else { - const temp = this.manager_ - const doWork = async (m: EntityManager): Promise => { - this.manager_ = m - this.transactionManager_ = m - try { - const result = await work(m) - this.manager_ = temp - this.transactionManager_ = undefined - return result - } catch (error) { - this.manager_ = temp - this.transactionManager_ = undefined - throw error - } - } - - if (isolation) { - let result - try { - result = await this.manager_.transaction( - isolation as IsolationLevel, - (m) => doWork(m) - ) - return result - } catch (error) { - if (this.shouldRetryTransaction(error)) { - return this.manager_.transaction(isolation as IsolationLevel, (m) => - doWork(m) - ) - } else { - if (errorHandler) { - await errorHandler(error) - } - throw error - } - } - } - - try { - return await this.manager_.transaction((m) => doWork(m)) - } catch (error) { - if (errorHandler) { - const result = await errorHandler(error) - if (dontFail) { - return result - } - } - - throw error - } - } - } - - /** - * Dedicated method to set metadata. - * @param obj - the entity to apply metadata to. - * @param metadata - the metadata to set - * @return resolves to the updated result. - */ - setMetadata_( - obj: { metadata: Record }, - metadata: Record - ): Record { - const existing = obj.metadata || {} - const newData = {} - for (const [key, value] of Object.entries(metadata)) { - if (typeof key !== "string") { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "Key type is invalid. Metadata keys must be strings" - ) - } - newData[key] = value - } - - return { - ...existing, - ...newData, - } - } -} -export default BaseService diff --git a/packages/medusa/src/interfaces/index.ts b/packages/medusa/src/interfaces/index.ts index 7552d3c149..9472f26f30 100644 --- a/packages/medusa/src/interfaces/index.ts +++ b/packages/medusa/src/interfaces/index.ts @@ -1,4 +1,4 @@ export * from "./tax-calculation-strategy" export * from "./cart-completion-strategy" export * from "./tax-service" -export * from "./base-service" +export * from "./transaction-base-service" diff --git a/packages/medusa/src/interfaces/transaction-base-service.ts b/packages/medusa/src/interfaces/transaction-base-service.ts new file mode 100644 index 0000000000..96d472bb23 --- /dev/null +++ b/packages/medusa/src/interfaces/transaction-base-service.ts @@ -0,0 +1,149 @@ +import { EntityManager } from "typeorm" +import { IsolationLevel } from "typeorm/driver/types/IsolationLevel" + +export abstract class TransactionBaseService< + TChild extends TransactionBaseService, + TContainer = unknown +> { + protected abstract manager_: EntityManager + protected abstract transactionManager_: EntityManager | undefined + + protected constructor( + protected readonly container: TContainer, + protected readonly configModule?: Record + ) {} + + withTransaction(transactionManager?: EntityManager): this | TChild { + if (!transactionManager) { + return this + } + + const cloned = new (this.constructor)( + { + ...this.container, + manager: transactionManager, + }, + this.configModule + ) + + cloned.transactionManager_ = transactionManager + + return cloned as TChild + } + + protected shouldRetryTransaction_( + err: { code: string } | Record + ): boolean { + if (!(err as { code: string })?.code) { + return false + } + const code = (err as { code: string })?.code + return code === "40001" || code === "40P01" + } + + /** + * Wraps some work within a transactional block. If the service already has + * a transaction manager attached this will be reused, otherwise a new + * transaction manager is created. + * @param work - the transactional work to be done + * @param isolationOrErrorHandler - the isolation level to be used for the work. + * @param maybeErrorHandlerOrDontFail Potential error handler + * @return the result of the transactional work + */ + protected async atomicPhase_( + work: (transactionManager: EntityManager) => Promise, + isolationOrErrorHandler?: + | IsolationLevel + | ((error: TError) => Promise), + maybeErrorHandlerOrDontFail?: ( + error: TError + ) => Promise + ): Promise { + let errorHandler = maybeErrorHandlerOrDontFail + let isolation: + | IsolationLevel + | ((error: TError) => Promise) + | undefined + | null = isolationOrErrorHandler + let dontFail = false + if (typeof isolationOrErrorHandler === "function") { + isolation = null + errorHandler = isolationOrErrorHandler + dontFail = !!maybeErrorHandlerOrDontFail + } + + if (this.transactionManager_) { + const doWork = async (m: EntityManager): Promise => { + this.manager_ = m + this.transactionManager_ = m + try { + return await work(m) + } catch (error) { + if (errorHandler) { + const queryRunner = this.transactionManager_.queryRunner + if (queryRunner && queryRunner.isTransactionActive) { + await queryRunner.rollbackTransaction() + } + + await errorHandler(error) + } + throw error + } + } + + return await doWork(this.transactionManager_) + } else { + const temp = this.manager_ + const doWork = async (m: EntityManager): Promise => { + this.manager_ = m + this.transactionManager_ = m + try { + const result = await work(m) + this.manager_ = temp + this.transactionManager_ = undefined + return result + } catch (error) { + this.manager_ = temp + this.transactionManager_ = undefined + throw error + } + } + + if (isolation && this.manager_) { + let result + try { + result = await this.manager_.transaction( + isolation as IsolationLevel, + (m) => doWork(m) + ) + return result + } catch (error) { + if (this.shouldRetryTransaction_(error)) { + return this.manager_.transaction( + isolation as IsolationLevel, + (m): Promise => doWork(m) + ) + } else { + if (errorHandler) { + await errorHandler(error) + } + throw error + } + } + } + + try { + return await this.manager_.transaction((m) => doWork(m)) + } catch (error) { + if (errorHandler) { + const result = await errorHandler(error) + if (dontFail) { + return result as TResult + } + } + + throw error + } + } + } +} diff --git a/packages/medusa/src/loaders/__tests__/plugins.spec.ts b/packages/medusa/src/loaders/__tests__/plugins.spec.ts new file mode 100644 index 0000000000..e193a70d8c --- /dev/null +++ b/packages/medusa/src/loaders/__tests__/plugins.spec.ts @@ -0,0 +1,61 @@ +import { createContainer, asValue } from "awilix" +import { mkdirSync, rmSync, rmdirSync, writeFileSync } from "fs" +import { resolve } from "path" +import Logger from "../logger" +import { registerServices } from "../plugins" +import { MedusaContainer } from "../../types/global" + +const distTestTargetDirectorPath = resolve(__dirname, "__pluginsLoaderTest__") +const servicesTestTargetDirectoryPath = resolve(distTestTargetDirectorPath, "services") +const buildServiceTemplate = (name: string) => { + return ` + import { BaseService } from "medusa-interfaces" + export default class ${name}Service extends BaseService {} + ` +} + +describe('plugins loader', () => { + const container = createContainer() as MedusaContainer + const pluginsDetails = { + resolve: resolve(__dirname, "__pluginsLoaderTest__"), + name: `project-plugin`, + id: "fakeId", + options: {}, + version: '"fakeVersion', + } + + describe("registerServices", function() { + beforeAll(() => { + container.register("logger", asValue(Logger)) + mkdirSync(servicesTestTargetDirectoryPath, { mode: "777", recursive: true }) + writeFileSync(resolve(servicesTestTargetDirectoryPath, "test.js"), buildServiceTemplate("test")) + writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.js"), buildServiceTemplate("test2")) + writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.js.map"), "map:file") + writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.d.ts"), "export interface Test {}") + }) + + afterAll(() => { + rmSync(distTestTargetDirectorPath, { recursive: true, force: true }) + jest.clearAllMocks() + }) + + it('should load the services from the services directory but only js files', async () => { + let err; + try { + await registerServices(pluginsDetails, container) + } catch (e) { + err = e + } + + expect(err).toBeFalsy() + + const testService: (...args: unknown[]) => any = container.resolve("testService") + const test2Service: (...args: unknown[]) => any = container.resolve("test2Service") + + expect(testService).toBeTruthy() + expect(testService.constructor.name).toBe("testService") + expect(test2Service).toBeTruthy() + expect(test2Service.constructor.name).toBe("test2Service") + }) + }) +}) \ No newline at end of file diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index ae831ddbeb..7c24df9b17 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -245,8 +245,8 @@ function registerApi( * registered * @return {void} */ -async function registerServices(pluginDetails: PluginDetails, container: MedusaContainer): Promise { - const files = glob.sync(`${pluginDetails.resolve}/services/[!__]*`, {}) +export async function registerServices(pluginDetails: PluginDetails, container: MedusaContainer): Promise { + const files = glob.sync(`${pluginDetails.resolve}/services/[!__]*.js`, {}) await Promise.all( files.map(async (fn) => { const loaded = require(fn).default diff --git a/packages/medusa/src/repositories/money-amount.ts b/packages/medusa/src/repositories/money-amount.ts index 63713651b7..716db232cf 100644 --- a/packages/medusa/src/repositories/money-amount.ts +++ b/packages/medusa/src/repositories/money-amount.ts @@ -116,6 +116,24 @@ export class MoneyAmountRepository extends Repository { .execute() } + public async findManyForVariantInPriceList( + variant_id: string, + price_list_id: string + ): Promise<[MoneyAmount[], number]> { + const qb = this.createQueryBuilder("ma") + .leftJoinAndSelect("ma.price_list", "price_list") + .where("ma.variant_id = :variant_id", { variant_id }) + .andWhere( + new Brackets((qb) => { + qb.where("ma.price_list_id = :price_list_id", { + price_list_id, + }).orWhere("ma.price_list_id IS NULL") + }) + ) + + return await qb.getManyAndCount() + } + public async findManyForVariantInRegion( variant_id: string, region_id?: string, @@ -126,21 +144,17 @@ export class MoneyAmountRepository extends Repository { const date = new Date() const qb = this.createQueryBuilder("ma") - .leftJoinAndSelect( - "ma.price_list", - "price_list", - "ma.price_list_id = price_list.id " - ) + .leftJoinAndSelect("ma.price_list", "price_list") .where({ variant_id: variant_id }) .andWhere("(ma.price_list_id is null or price_list.status = 'active')") .andWhere( - "(price_list is null or price_list.ends_at is null OR price_list.ends_at > :date) ", + "(price_list.ends_at is null OR price_list.ends_at > :date)", { date: date.toUTCString(), } ) .andWhere( - "(price_list is null or price_list.starts_at is null OR price_list.starts_at < :date)", + "(price_list.starts_at is null OR price_list.starts_at < :date)", { date: date.toUTCString(), } @@ -155,23 +169,19 @@ export class MoneyAmountRepository extends Repository { ) ) } else if (!customer_id && !include_discount_prices) { - qb.andWhere("price_list IS null") + qb.andWhere("price_list.id IS null") } if (customer_id) { qb.leftJoin("price_list.customer_groups", "cgroup") - .leftJoin( - "customer_group_customers", - "cgc", - "cgc.customer_group_id = cgroup.id" - ) - .andWhere("(cgc is null OR cgc.customer_id = :customer_id)", { + .leftJoin("customer_group_customers", "cgc", "cgc.customer_group_id = cgroup.id") + .andWhere("(cgc.customer_group_id is null OR cgc.customer_id = :customer_id)", { customer_id, }) } else { - qb.leftJoin("price_list.customer_groups", "cgroup").andWhere( - "cgroup.id is null" - ) + qb + .leftJoin("price_list.customer_groups", "cgroup") + .andWhere("cgroup.id is null") } return await qb.getManyAndCount() } diff --git a/packages/medusa/src/repositories/price-list.ts b/packages/medusa/src/repositories/price-list.ts index bcf37926bf..0eac64a2d2 100644 --- a/packages/medusa/src/repositories/price-list.ts +++ b/packages/medusa/src/repositories/price-list.ts @@ -1,5 +1,95 @@ -import { EntityRepository, Repository } from "typeorm" +import { groupBy, map } from "lodash" +import { + Brackets, + EntityRepository, + FindManyOptions, Repository +} from "typeorm" import { PriceList } from "../models/price-list" +import { CustomFindOptions } from "../types/common" + +type PriceListFindOptions = CustomFindOptions @EntityRepository(PriceList) -export class PriceListRepository extends Repository {} +export class PriceListRepository extends Repository { + public async getFreeTextSearchResultsAndCount( + q: string, + options: PriceListFindOptions = { where: {} }, + relations: (keyof PriceList)[] = [] + ): Promise<[PriceList[], number]> { + options.where = options.where ?? {} + let qb = this.createQueryBuilder("price_list") + .leftJoinAndSelect("price_list.customer_groups", "customer_group") + .select(["price_list.id"]) + .where(options.where) + .andWhere( + new Brackets((qb) => { + qb.where(`price_list.description ILIKE :q`, { q: `%${q}%` }) + .orWhere(`price_list.name ILIKE :q`, { q: `%${q}%` }) + .orWhere(`customer_group.name ILIKE :q`, { q: `%${q}%` }) + }) + ) + .skip(options.skip) + .take(options.take) + + const [results, count] = await qb.getManyAndCount() + + const price_lists = await this.findWithRelations( + relations, + results.map((r) => r.id) + ) + + return [price_lists, count] + } + + public async findWithRelations( + relations: (keyof PriceList)[] = [], + idsOrOptionsWithoutRelations: + | Omit, "relations"> + | string[] = {} + ): Promise { + let entities + if (Array.isArray(idsOrOptionsWithoutRelations)) { + entities = await this.findByIds(idsOrOptionsWithoutRelations) + } else { + entities = await this.find(idsOrOptionsWithoutRelations) + } + + const groupedRelations: Record = {} + for (const relation of relations) { + const [topLevel] = relation.split(".") + if (groupedRelations[topLevel]) { + groupedRelations[topLevel].push(relation) + } else { + groupedRelations[topLevel] = [relation] + } + } + + const entitiesIds = entities.map(({ id }) => id) + const entitiesIdsWithRelations = await Promise.all( + Object.values(groupedRelations).map((relations: string[]) => { + return this.findByIds(entitiesIds, { + select: ["id"], + relations: relations as string[], + }) + }) + ).then(entitiesIdsWithRelations => entitiesIdsWithRelations.flat()) + const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) + + const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id") + return map(entitiesAndRelationsById, (entityAndRelations) => + this.merge(this.create(), ...entityAndRelations) + ) + } + + public async findOneWithRelations( + relations: (keyof PriceList)[] = [], + options: Omit, "relations"> = {} + ): Promise { + options.take = 1 + + return (await this.findWithRelations( + relations, + options + ))?.pop() + } +} diff --git a/packages/medusa/src/services/__tests__/line-item.js b/packages/medusa/src/services/__tests__/line-item.js index 0642e9791d..d36bd8363a 100644 --- a/packages/medusa/src/services/__tests__/line-item.js +++ b/packages/medusa/src/services/__tests__/line-item.js @@ -3,7 +3,9 @@ import LineItemService from "../line-item" describe("LineItemService", () => { describe("create", () => { - const lineItemRepository = MockRepository({}) + const lineItemRepository = MockRepository({ + create: (data) => data + }) const cartRepository = MockRepository({ findOne: () => @@ -105,9 +107,10 @@ describe("LineItemService", () => { }) it("successfully create a line item giftcard", async () => { - const line = await await lineItemService.generate( + const line = await lineItemService.generate( IdMap.getId("test-giftcard"), - IdMap.getId("test-region") + IdMap.getId("test-region"), + 1 ) await lineItemService.create({ @@ -115,8 +118,8 @@ describe("LineItemService", () => { cart_id: IdMap.getId("test-cart"), }) - expect(lineItemRepository.create).toHaveBeenCalledTimes(1) - expect(lineItemRepository.create).toHaveBeenCalledWith({ + expect(lineItemRepository.create).toHaveBeenCalledTimes(2) + expect(lineItemRepository.create).toHaveBeenNthCalledWith(2, expect.objectContaining({ allow_discounts: false, variant_id: IdMap.getId("test-giftcard"), cart_id: IdMap.getId("test-cart"), @@ -128,7 +131,7 @@ describe("LineItemService", () => { is_giftcard: true, should_merge: true, metadata: {}, - }) + })) }) }) diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index ae1755f6cb..7df4de88c6 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -1,6 +1,5 @@ import _ from "lodash" import { MedusaError, Validator } from "medusa-core-utils" -import { BaseService } from "medusa-interfaces" import { DeepPartial, EntityManager, In } from "typeorm" import { IPriceSelectionStrategy } from "../interfaces/price-selection-strategy" import { Address } from "../models/address" @@ -37,7 +36,8 @@ import InventoryService from "./inventory" import CustomShippingOptionService from "./custom-shipping-option" import LineItemAdjustmentService from "./line-item-adjustment" import { LineItemRepository } from "../repositories/line-item" -import { raw } from "express" +import { TransactionBaseService } from "../interfaces" +import { buildQuery, setMetadata, validateId } from "../utils" type InjectedDependencies = { manager: EntityManager @@ -71,14 +71,16 @@ type TotalsConfig = { /* Provides layer to manipulate carts. * @implements BaseService */ -class CartService extends BaseService { - static Events = { +class CartService extends TransactionBaseService { + static readonly Events = { CUSTOMER_UPDATED: "cart.customer_updated", CREATED: "cart.created", UPDATED: "cart.updated", } - protected readonly manager_: EntityManager + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + protected readonly shippingMethodRepository_: typeof ShippingMethodRepository protected readonly cartRepository_: typeof CartRepository protected readonly addressRepository_: typeof AddressRepository @@ -125,7 +127,8 @@ class CartService extends BaseService { lineItemAdjustmentService, priceSelectionStrategy, }: InjectedDependencies) { - super() + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) this.manager_ = manager this.shippingMethodRepository_ = shippingMethodRepository @@ -151,41 +154,6 @@ class CartService extends BaseService { this.priceSelectionStrategy_ = priceSelectionStrategy } - withTransaction(transactionManager: EntityManager): CartService { - if (!transactionManager) { - return this - } - - const cloned = new CartService({ - manager: transactionManager, - taxProviderService: this.taxProviderService_, - cartRepository: this.cartRepository_, - lineItemRepository: this.lineItemRepository_, - eventBusService: this.eventBus_, - paymentProviderService: this.paymentProviderService_, - paymentSessionRepository: this.paymentSessionRepository_, - shippingMethodRepository: this.shippingMethodRepository_, - productService: this.productService_, - productVariantService: this.productVariantService_, - regionService: this.regionService_, - lineItemService: this.lineItemService_, - shippingOptionService: this.shippingOptionService_, - customerService: this.customerService_, - discountService: this.discountService_, - totalsService: this.totalsService_, - addressRepository: this.addressRepository_, - giftCardService: this.giftCardService_, - inventoryService: this.inventoryService_, - customShippingOptionService: this.customShippingOptionService_, - lineItemAdjustmentService: this.lineItemAdjustmentService_, - priceSelectionStrategy: this.priceSelectionStrategy_, - }) - - cloned.transactionManager_ = transactionManager - - return cloned - } - protected transformQueryForTotals_( config: FindConfig ): FindConfig & { totalsToSelect: TotalField[] } { @@ -293,7 +261,7 @@ class CartService extends BaseService { this.cartRepository_ ) - const query = this.buildQuery_(selector, config) + const query = buildQuery(selector, config) return await cartRepo.find(query) } ) @@ -316,12 +284,12 @@ class CartService extends BaseService { const cartRepo = transactionManager.getCustomRepository( this.cartRepository_ ) - const validatedId = this.validateId_(cartId) + const validatedId = validateId(cartId) const { select, relations, totalsToSelect } = this.transformQueryForTotals_(options) - const query = this.buildQuery_( + const query = buildQuery( { id: validatedId }, { ...options, select, relations } ) @@ -706,16 +674,20 @@ class CartService extends BaseService { * @param shouldAdd - flag to indicate, if we should add or remove * @return void */ - async adjustFreeShipping_(cart: Cart, shouldAdd: boolean): Promise { + protected async adjustFreeShipping_( + cart: Cart, + shouldAdd: boolean + ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + if (cart.shipping_methods?.length) { - const shippingMethodRepository = - this.transactionManager_.getCustomRepository( - this.shippingMethodRepository_ - ) + const shippingMethodRepository = transactionManager.getCustomRepository( + this.shippingMethodRepository_ + ) // if any free shipping discounts, we ensure to update shipping method amount if (shouldAdd) { - return shippingMethodRepository.update( + await shippingMethodRepository.update( { id: In( cart.shipping_methods.map((shippingMethod) => shippingMethod.id) @@ -847,8 +819,8 @@ class CartService extends BaseService { ) } - if ("metadata" in data) { - cart.metadata = this.setMetadata_(cart, data.metadata) + if (data?.metadata) { + cart.metadata = setMetadata(cart, data.metadata) } if ("context" in data) { @@ -1344,7 +1316,7 @@ class CartService extends BaseService { * @param cartOrCartId - the id of the cart to set payment session for * @return the result of the update operation. */ - async setPaymentSessions(cartOrCartId: Cart | string): Promise { + async setPaymentSessions(cartOrCartId: Cart | string): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const psRepo = transactionManager.getCustomRepository( @@ -1671,6 +1643,8 @@ class CartService extends BaseService { regionId?: string, customer_id?: string ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + // If the cart contains items, we update the price of the items // to match the updated region or customer id (keeping the old // value if it exists) @@ -1685,7 +1659,7 @@ class CartService extends BaseService { await Promise.all( cart.items.map(async (item) => { const availablePrice = await this.priceSelectionStrategy_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .calculateVariantPrice(item.variant_id, { region_id: region.id, currency_code: region.currency_code, @@ -1700,14 +1674,14 @@ class CartService extends BaseService { availablePrice.calculatedPrice !== null ) { return this.lineItemService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .update(item.id, { has_shipping: false, unit_price: availablePrice.calculatedPrice, }) } else { await this.lineItemService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .delete(item.id) return } @@ -1729,6 +1703,8 @@ class CartService extends BaseService { regionId: string, countryCode: string | null ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + if (cart.completed_at || cart.payment_authorized_at) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, @@ -1742,7 +1718,7 @@ class CartService extends BaseService { .retrieve(regionId, { relations: ["countries"], }) - const addrRepo = this.transactionManager_.getCustomRepository( + const addrRepo = transactionManager.getCustomRepository( this.addressRepository_ ) cart.region = region @@ -1820,7 +1796,7 @@ class CartService extends BaseService { // new shipping method if (cart.shipping_methods && cart.shipping_methods.length) { await this.shippingOptionService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .deleteShippingMethods(cart.shipping_methods) } @@ -1833,7 +1809,7 @@ class CartService extends BaseService { cart.gift_cards = [] if (cart.payment_sessions && cart.payment_sessions.length) { - const paymentSessionRepo = this.transactionManager_.getCustomRepository( + const paymentSessionRepo = transactionManager.getCustomRepository( this.paymentSessionRepository_ ) await paymentSessionRepo.delete({ @@ -1851,7 +1827,7 @@ class CartService extends BaseService { * @param cartId - the id of the cart to delete * @return the deleted cart or undefined if the cart was not found. */ - async delete(cartId: string): Promise { + async delete(cartId: string): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const cart = await this.retrieve(cartId, { @@ -1905,7 +1881,7 @@ class CartService extends BaseService { this.cartRepository_ ) - const validatedId = this.validateId_(cartId) + const validatedId = validateId(cartId) if (typeof key !== "string") { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, @@ -1964,20 +1940,22 @@ class CartService extends BaseService { } protected async refreshAdjustments_(cart: Cart): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + const nonReturnLineIDs = cart.items .filter((item) => !item.is_return) .map((i) => i.id) // delete all old non return line item adjustments await this.lineItemAdjustmentService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .delete({ item_id: nonReturnLineIDs, }) // potentially create/update line item adjustments await this.lineItemAdjustmentService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .createAdjustments(cart) } @@ -1993,7 +1971,7 @@ class CartService extends BaseService { const cartRepo = transactionManager.getCustomRepository( this.cartRepository_ ) - const validatedId = this.validateId_(cartId) + const validatedId = validateId(cartId) if (typeof key !== "string") { throw new MedusaError( diff --git a/packages/medusa/src/services/line-item.js b/packages/medusa/src/services/line-item.js deleted file mode 100644 index e48f0892f4..0000000000 --- a/packages/medusa/src/services/line-item.js +++ /dev/null @@ -1,282 +0,0 @@ -import { MedusaError } from "medusa-core-utils" -import { BaseService } from "medusa-interfaces" - -/** - * Provides layer to manipulate line items. - * @extends BaseService - */ -class LineItemService extends BaseService { - constructor({ - manager, - lineItemRepository, - lineItemTaxLineRepository, - productVariantService, - productService, - regionService, - cartRepository, - lineItemAdjustmentService, - }) { - super() - - /** @private @const {EntityManager} */ - this.manager_ = manager - - /** @private @const {LineItemRepository} */ - this.lineItemRepository_ = lineItemRepository - - /** @private @const {typeof LineItemTaxLineRepository} */ - this.itemTaxLineRepo_ = lineItemTaxLineRepository - - /** @private @const {ProductVariantService} */ - this.productVariantService_ = productVariantService - - /** @private @const {ProductService} */ - this.productService_ = productService - - /** @private @const {RegionService} */ - this.regionService_ = regionService - - /** @private @const {CartRepository} */ - this.cartRepository_ = cartRepository - - this.lineItemAdjustmentService_ = lineItemAdjustmentService - } - - withTransaction(transactionManager) { - if (!transactionManager) { - return this - } - - const cloned = new LineItemService({ - manager: transactionManager, - lineItemRepository: this.lineItemRepository_, - lineItemTaxLineRepository: this.itemTaxLineRepo_, - productVariantService: this.productVariantService_, - productService: this.productService_, - regionService: this.regionService_, - cartRepository: this.cartRepository_, - lineItemAdjustmentService: this.lineItemAdjustmentService_, - }) - - cloned.transactionManager_ = transactionManager - - return cloned - } - - async list( - selector, - config = { skip: 0, take: 50, order: { created_at: "DESC" } } - ) { - const liRepo = this.manager_.getCustomRepository(this.lineItemRepository_) - const query = this.buildQuery_(selector, config) - return liRepo.find(query) - } - - /** - * Retrieves a line item by its id. - * @param {string} id - the id of the line item to retrieve - * @param {object} config - the config to be used at query building - * @return {LineItem} the line item - */ - async retrieve(id, config = {}) { - const lineItemRepository = this.manager_.getCustomRepository( - this.lineItemRepository_ - ) - - const validatedId = this.validateId_(id) - const query = this.buildQuery_({ id: validatedId }, config) - - const lineItem = await lineItemRepository.findOne(query) - - if (!lineItem) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Line item with ${id} was not found` - ) - } - - return lineItem - } - - /** - * Creates return line items for a given cart based on the return items in a - * return. - * @param {string} returnId - the id to generate return items from. - * @param {string} cartId - the cart to assign the return line items to. - * @return {Promise} the created line items - */ - async createReturnLines(returnId, cartId) { - const lineItemRepo = this.manager_.getCustomRepository( - this.lineItemRepository_ - ) - - const itemTaxLineRepo = this.manager_.getCustomRepository( - this.itemTaxLineRepo_ - ) - - const items = await lineItemRepo.findByReturn(returnId) - - const toCreate = items.map((i) => - lineItemRepo.create({ - cart_id: cartId, - thumbnail: i.thumbnail, - is_return: true, - title: i.title, - variant_id: i.variant_id, - unit_price: -1 * i.unit_price, - quantity: i.return_item.quantity, - allow_discounts: i.allow_discounts, - tax_lines: i.tax_lines.map((tl) => { - return itemTaxLineRepo.create({ - name: tl.name, - code: tl.code, - rate: tl.rate, - metadata: tl.metadata, - }) - }), - metadata: i.metadata, - adjustments: i.adjustments.map((adjustment) => { - return { - amount: -1 * adjustment.amount, - description: adjustment.description, - discount_id: adjustment.discount_id, - metadata: adjustment.metadata, - } - }), - }) - ) - - return await lineItemRepo.save(toCreate) - } - - async generate(variantId, regionId, quantity, context = {}) { - return this.atomicPhase_(async (manager) => { - const variant = await this.productVariantService_ - .withTransaction(manager) - .retrieve(variantId, { - relations: ["product"], - include_discount_prices: true, - }) - - const region = await this.regionService_ - .withTransaction(manager) - .retrieve(regionId) - - let price - let shouldMerge = true - - if (context.unit_price !== undefined && context.unit_price !== null) { - // if custom unit_price, we ensure positive values - // and we choose to not merge the items - shouldMerge = false - if (context.unit_price < 0) { - price = 0 - } else { - price = context.unit_price - } - } else { - price = await this.productVariantService_ - .withTransaction(manager) - .getRegionPrice(variant.id, { - regionId: region.id, - quantity: quantity, - customer_id: context.customer_id, - include_discount_prices: true, - }) - } - - const toCreate = { - unit_price: price, - title: variant.product.title, - description: variant.title, - thumbnail: variant.product.thumbnail, - variant_id: variant.id, - quantity: quantity || 1, - allow_discounts: variant.product.discountable, - is_giftcard: variant.product.is_giftcard, - metadata: context?.metadata || {}, - should_merge: shouldMerge, - } - - if (context.cart) { - const adjustments = await this.lineItemAdjustmentService_ - .withTransaction(manager) - .generateAdjustments(context.cart, toCreate, { variant }) - toCreate.adjustments = adjustments - } - - return toCreate - }) - } - - /** - * Create a line item - * @param {LineItem} lineItem - the line item object to create - * @return {LineItem} the created line item - */ - async create(lineItem) { - return this.atomicPhase_(async (manager) => { - const lineItemRepository = manager.getCustomRepository( - this.lineItemRepository_ - ) - - const created = await lineItemRepository.create(lineItem) - const result = await lineItemRepository.save(created) - return result - }) - } - - /** - * Updates a line item - * @param {string} id - the id of the line item to update - * @param {object} update - the properties to update on line item - * @return {LineItem} the update line item - */ - async update(id, update) { - return this.atomicPhase_(async (manager) => { - const lineItemRepository = manager.getCustomRepository( - this.lineItemRepository_ - ) - - const lineItem = await this.retrieve(id) - - const { metadata, ...rest } = update - - if (metadata) { - lineItem.metadata = this.setMetadata_(lineItem, metadata) - } - - for (const [key, value] of Object.entries(rest)) { - lineItem[key] = value - } - - const result = await lineItemRepository.save(lineItem) - return result - }) - } - - /** - * Deletes a line item. - * @param {string} id - the id of the line item to delete - * @return {Promise} the result of the delete operation - */ - async delete(id) { - return this.atomicPhase_(async (manager) => { - const lineItemRepository = manager.getCustomRepository( - this.lineItemRepository_ - ) - - const lineItem = await lineItemRepository.findOne({ where: { id } }) - - if (!lineItem) { - return Promise.resolve() - } - - await lineItemRepository.remove(lineItem) - - return Promise.resolve() - }) - } -} - -export default LineItemService diff --git a/packages/medusa/src/services/line-item.ts b/packages/medusa/src/services/line-item.ts index 89fd8befcd..55e55a802c 100644 --- a/packages/medusa/src/services/line-item.ts +++ b/packages/medusa/src/services/line-item.ts @@ -236,10 +236,10 @@ class LineItemService extends BaseService { should_merge: shouldMerge, } - const lineLitemRepo = transactionManager.getCustomRepository( + const lineItemRepo = transactionManager.getCustomRepository( this.lineItemRepository_ ) - const lineItem = lineLitemRepo.create(rawLineItem) + const lineItem = lineItemRepo.create(rawLineItem) if (context.cart) { const adjustments = await this.lineItemAdjustmentService_ diff --git a/packages/medusa/src/services/price-list.ts b/packages/medusa/src/services/price-list.ts index 0312b6e81e..38bae29080 100644 --- a/packages/medusa/src/services/price-list.ts +++ b/packages/medusa/src/services/price-list.ts @@ -2,6 +2,7 @@ import { MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" import { EntityManager } from "typeorm" import { CustomerGroupService } from "." +import { Product } from "../models" import { CustomerGroup } from "../models/customer-group" import { PriceList } from "../models/price-list" import { MoneyAmountRepository } from "../repositories/money-amount" @@ -14,10 +15,12 @@ import { UpdatePriceListInput, } from "../types/price-list" import { formatException } from "../utils/exception-formatter" +import ProductService from "./product" type PriceListConstructorProps = { manager: EntityManager customerGroupService: CustomerGroupService + productService: ProductService priceListRepository: typeof PriceListRepository moneyAmountRepository: typeof MoneyAmountRepository } @@ -29,18 +32,21 @@ type PriceListConstructorProps = { class PriceListService extends BaseService { private manager_: EntityManager private customerGroupService_: CustomerGroupService + private productService_: ProductService private priceListRepo_: typeof PriceListRepository private moneyAmountRepo_: typeof MoneyAmountRepository constructor({ manager, customerGroupService, + productService, priceListRepository, moneyAmountRepository, }: PriceListConstructorProps) { super() this.manager_ = manager this.customerGroupService_ = customerGroupService + this.productService_ = productService this.priceListRepo_ = priceListRepository this.moneyAmountRepo_ = moneyAmountRepository } @@ -53,6 +59,7 @@ class PriceListService extends BaseService { const cloned = new PriceListService({ manager: transactionManager, customerGroupService: this.customerGroupService_, + productService: this.productService_, priceListRepository: this.priceListRepo_, moneyAmountRepository: this.moneyAmountRepo_, }) @@ -253,9 +260,18 @@ class PriceListService extends BaseService { config: FindConfig = { skip: 0, take: 20 } ): Promise<[PriceList[], number]> { const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_) + const q = selector.q + const { relations, ...query } = this.buildQuery_(selector, config) - const query = this.buildQuery_(selector, config) - return await priceListRepo.findAndCount(query) + if (q) { + delete query.where.q + return await priceListRepo.getFreeTextSearchResultsAndCount( + q, + query, + relations + ) + } + return await priceListRepo.findAndCount({ ...query, relations }) } async upsertCustomerGroups_( @@ -276,6 +292,50 @@ class PriceListService extends BaseService { await priceListRepo.save(priceList) } + + async listProducts( + priceListId: string, + selector = {}, + config: FindConfig = { + relations: [], + skip: 0, + take: 20, + } + ): Promise<[Product[], number]> { + return await this.atomicPhase_(async (manager: EntityManager) => { + const [products, count] = await this.productService_.listAndCount( + selector, + config + ) + + const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_) + + const productsWithPrices = await Promise.all( + products.map(async (p) => { + if (p.variants?.length) { + p.variants = await Promise.all( + p.variants.map(async (v) => { + const [prices] = + await moneyAmountRepo.findManyForVariantInPriceList( + v.id, + priceListId + ) + + return { + ...v, + prices, + } + }) + ) + } + + return p + }) + ) + + return [productsWithPrices, count] + }) + } } export default PriceListService diff --git a/packages/medusa/src/services/product-variant.ts b/packages/medusa/src/services/product-variant.ts index 62fe890150..2d8f6cf4af 100644 --- a/packages/medusa/src/services/product-variant.ts +++ b/packages/medusa/src/services/product-variant.ts @@ -751,7 +751,7 @@ class ProductVariantService extends BaseService { const variant = await variantRepo.findOne({ where: { id: variantId }, - relations: ["prices"], + relations: ["prices", "options"], }) if (!variant) { diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index 5e1da6caa8..b746e2e049 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -668,7 +668,7 @@ class ProductService extends BaseService { // Should not fail, if product does not exist, since delete is idempotent const product = await productRepo.findOne( { id: productId }, - { relations: ["variants"] } + { relations: ["variants", "variants.prices", "variants.options"] } ) if (!product) { diff --git a/packages/medusa/src/types/cart.ts b/packages/medusa/src/types/cart.ts index 249c0c9af0..dfba20b82f 100644 --- a/packages/medusa/src/types/cart.ts +++ b/packages/medusa/src/types/cart.ts @@ -71,5 +71,5 @@ export type CartUpdateProps = { discounts?: Discount[] customer_id?: string context?: object - metadata?: object + metadata?: Record } diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index 94f06c01e1..da52f14025 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -1,12 +1,34 @@ import { Transform, Type } from "class-transformer" import { IsDate, IsNumber, IsOptional, IsString } from "class-validator" import "reflect-metadata" +import { + BaseEntity, + FindManyOptions, + FindOperator, + OrderByCondition, +} from "typeorm" import { transformDate } from "../utils/validators/date-transform" export type PartialPick = { [P in K]?: T[P] } +export type Writable = { -readonly [key in keyof T]: T[key] } + +export type ExtendedFindConfig = FindConfig & { + where: Partial> + withDeleted?: boolean +} + +export type Selector = { + [key in keyof TEntity]?: + | TEntity[key] + | TEntity[key][] + | DateComparisonOperator + | StringComparisonOperator + | NumericalComparisonOperator +} + export type TotalField = | "shipping_total" | "discount_total" @@ -25,6 +47,17 @@ export interface FindConfig { order?: Record } +export interface CustomFindOptions { + select?: FindManyOptions["select"] + where?: FindManyOptions["where"] & + { + [P in InKeys]?: TModel[P][] + } + order?: OrderByCondition + skip?: number + take?: number +} + export type PaginatedResponse = { limit: number; offset: number; count: number } export type DeleteResponse = { diff --git a/packages/medusa/src/utils/__tests__/build-query.spec.ts b/packages/medusa/src/utils/__tests__/build-query.spec.ts new file mode 100644 index 0000000000..f9a4819390 --- /dev/null +++ b/packages/medusa/src/utils/__tests__/build-query.spec.ts @@ -0,0 +1,28 @@ +import { In, Not } from "typeorm" +import { buildQuery } from "../build-query" + +describe('buildQuery', () => { + describe("buildQuery_", () => { + it("successfully creates query", () => { + const q = buildQuery( + { + id: "1234", + test1: ["123", "12", "1"], + test2: Not("this"), + }, + { + relations: ["1234"], + } + ) + + expect(q).toEqual({ + where: { + id: "1234", + test1: In(["123", "12", "1"]), + test2: Not("this"), + }, + relations: ["1234"], + }) + }) + }) +}) \ No newline at end of file diff --git a/packages/medusa/src/utils/build-query.ts b/packages/medusa/src/utils/build-query.ts new file mode 100644 index 0000000000..f61123741d --- /dev/null +++ b/packages/medusa/src/utils/build-query.ts @@ -0,0 +1,113 @@ +import { + ExtendedFindConfig, + FindConfig, + Selector, + Writable, +} from "../types/common" +import { FindOperator, In, Raw } from "typeorm" + +/** +* Used to build TypeORM queries. +* @param selector The selector +* @param config The config +* @return The QueryBuilderConfig +*/ +export function buildQuery( + selector: Selector, + config: FindConfig = {} +): ExtendedFindConfig { + const build = ( + obj: Selector + ): Partial> => { + return Object.entries(obj).reduce((acc, [key, value]: any) => { + // Undefined values indicate that they have no significance to the query. + // If the query is looking for rows where a column is not set it should use null instead of undefined + if (typeof value === "undefined") { + return acc + } + + const subquery: { + operator: "<" | ">" | "<=" | ">=" + value: unknown + }[] = [] + + switch (true) { + case value instanceof FindOperator: + acc[key] = value + break + case Array.isArray(value): + acc[key] = In([...(value as unknown[])]) + break + case value !== null && typeof value === "object": + Object.entries(value).map(([modifier, val]) => { + switch (modifier) { + case "lt": + subquery.push({ operator: "<", value: val }) + break + case "gt": + subquery.push({ operator: ">", value: val }) + break + case "lte": + subquery.push({ operator: "<=", value: val }) + break + case "gte": + subquery.push({ operator: ">=", value: val }) + break + default: + acc[key] = value + break + } + }) + + if (subquery.length) { + acc[key] = Raw( + (a) => + subquery + .map((s, index) => `${a} ${s.operator} :${index}`) + .join(" AND "), + subquery.map((s) => s.value) + ) + } + break + default: + acc[key] = value + break + } + + return acc + }, {} as Partial>) + } + + const query: FindConfig & { + where: Partial> + withDeleted?: boolean + } = { + where: build(selector), + } + + if ("deleted_at" in selector) { + query.withDeleted = true + } + + if ("skip" in config) { + query.skip = config.skip + } + + if ("take" in config) { + query.take = config.take + } + + if ("relations" in config) { + query.relations = config.relations + } + + if ("select" in config) { + query.select = config.select + } + + if ("order" in config) { + query.order = config.order + } + + return query +} \ No newline at end of file diff --git a/packages/medusa/src/utils/index.ts b/packages/medusa/src/utils/index.ts new file mode 100644 index 0000000000..5d7ec9889f --- /dev/null +++ b/packages/medusa/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './build-query' +export * from './set-metadata' +export * from './validate-id' \ No newline at end of file diff --git a/packages/medusa/src/utils/set-metadata.ts b/packages/medusa/src/utils/set-metadata.ts new file mode 100644 index 0000000000..d27cddc5ed --- /dev/null +++ b/packages/medusa/src/utils/set-metadata.ts @@ -0,0 +1,29 @@ +import { MedusaError } from "medusa-core-utils/dist" + +/** +* Dedicated method to set metadata. +* @param obj - the entity to apply metadata to. +* @param metadata - the metadata to set +* @return resolves to the updated result. +*/ +export function setMetadata( + obj: { metadata: Record }, + metadata: Record +): Record { + const existing = obj.metadata || {} + const newData = {} + for (const [key, value] of Object.entries(metadata)) { + if (typeof key !== "string") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Key type is invalid. Metadata keys must be strings" + ) + } + newData[key] = value + } + + return { + ...existing, + ...newData, + } +} \ No newline at end of file diff --git a/packages/medusa/src/utils/validate-id.ts b/packages/medusa/src/utils/validate-id.ts new file mode 100644 index 0000000000..da6102e69a --- /dev/null +++ b/packages/medusa/src/utils/validate-id.ts @@ -0,0 +1,41 @@ +/** +* Confirms whether a given raw id is valid. Fails if the provided +* id is null or undefined. The validate function takes an optional config +* param, to support checking id prefix and length. +* @param rawId - the id to validate. +* @param config - optional config +* @returns the rawId given that nothing failed +*/ +import { MedusaError } from "medusa-core-utils/dist" + +export function validateId( + rawId: string, + config: { prefix?: string; length?: number } = {} +): string { + const { prefix, length } = config + if (!rawId) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Failed to validate id: ${rawId}` + ) + } + + if (prefix || length) { + const [pre, rand] = rawId.split("_") + if (prefix && pre !== prefix) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The provided id: ${rawId} does not adhere to prefix constraint: ${prefix}` + ) + } + + if (length && length !== rand.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The provided id: ${rawId} does not adhere to length constraint: ${length}` + ) + } + } + + return rawId +} \ No newline at end of file