From befc2f1c80b6aaeb3a5153f7fdeaa96cf832e46f Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Sun, 16 Jul 2023 20:19:23 +0200 Subject: [PATCH] feat(product): Create (+ workflow), delete, restore (#4459) * Feat: create product with product module * feat: create product wip * feat: create product wip * feat: update product relation and generate image migration * lint * conitnue implementation * continue implementation and add integration tests for produceService.create * Add integration tests for product creation at the module level for the complete flow * only use persist since write operations are always wrapped in a transaction which will be committed and flushed * simplify the transaction wrapper to make future changes easier * feat: move some utils to the utils package to simplify its usage * tests: fix unit tests * feat: create variants along side the product * Add more integration tests an update migrations * chore: Update actions workflow to include packages integration tests * small types and utils cleanup * chore: Add support for database debug option * chore: Add missing types in package.json from types and util, validate that all the models are sync with medusa * expose retrieve method * fix types issues * fix unit tests and move integration tests workflow with the plugins integration tests * chore: remove migration function export from the definition to prevent them to be ran by the medusa cli just in case * fix package.json script * chore: workflows * feat: start creating the create product workflow * feat: add empty step for prices and sales channel * tests: update scripts and action envs * fix imports * feat: Add proper soft deleted support + add product deletion service public api * chore: update migrations * chore: update migrations * chore: update todo * feat: Add product deletion to the create-product workflow as compensation * chore: cleanup product utils * feat: Add support for cascade soft-remove * feat: refactor repository to take into account withDeleted * fix integration tests * Add support for force delete -> delete, cleanup repositories and improvements * Add support for restoring a product and add integration tests * cleaup + tests * types * fix integration tests * remove unnecessary comments * move specific mikro orm usage to the DAL * Cleanup workflow functions * Make deleted_at optional at the property level and add url index for the images * address feedback + cleanup * fix export * merge migrations into one * feat(product, types): added missing product variant methods (#4475) * chore: added missing product variant methods * chore: address PR feedback * chore: catch undefined case for retrieve + specs for variant service * chore: align TEntity + add changeset * chore: revert changeset, TEntity to ProductVariant * chore: write tests for pagination, unskip the test * Create chilled-mice-deliver.md * update integration fixtuers * update pipeline node version * rename github action * fix pipeline * feat(medusa, types): added missing category tests and service methods (#4499) * chore: added missing category tests and service methods * chore: added type changes to module service * chore: address pr feedback * update repositories manager usage and serialisation from the write public API * move serializisation to the DAL * rename template args * chore: added collection methods for module and collection service (#4505) * chore: added collection methods for module and collection service * Create fresh-islands-teach.md * chore: move retrieve entity to utils package * chore: make products optional in DTO type --------- Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> * feat(product): Apply transaction decorators to the services (#4512) --------- Co-authored-by: Riqwan Thamir Co-authored-by: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Co-authored-by: Carlos R. L. Rodrigues <37986729+carlos-r-l-rodrigues@users.noreply.github.com> --- .changeset/chilled-mice-deliver.md | 6 + .changeset/fresh-islands-teach.md | 6 + .github/workflows/action.yml | 62 +++ .../inventory/inventory-items/index.js | 24 +- package.json | 2 +- .../admin/create-product/create-product.ts | 159 +++++++ .../functions/attach-inventory-items.ts | 34 ++ .../functions/create-inventory-items.ts | 55 +++ .../src/workflows/functions/create-prducts.ts | 12 + .../medusa/src/workflows/functions/index.ts | 5 + .../functions/remove-inventory-items.ts | 26 ++ .../workflows/functions/remove-products.ts | 12 + .../integration-tests/__fixtures__/module.ts | 15 +- .../product/data/create-product.ts | 80 ++++ .../__fixtures__/product/index.ts | 47 ++- .../variant/data/create-variant.ts | 44 ++ .../__fixtures__/variant/index.ts | 1 + .../integration-tests/__tests__/module.ts | 259 +++++++++++- .../services/product-category/index.ts | 338 ++++++++++++++- .../services/product-collection/index.ts | 184 +++++++- .../product-categories.spec.ts | 248 +++++++++++ .../product-collections.spec.ts | 250 +++++++++++ .../product-variants.spec.ts | 182 ++++++++ .../__tests__/services/product-tag/index.ts | 2 +- .../services/product-variant/index.ts | 164 +++++++- .../__tests__/services/product/index.ts | 145 ++++++- packages/product/integration-tests/setup.js | 8 +- packages/product/package.json | 1 + packages/product/src/initialize/index.ts | 12 +- packages/product/src/loaders/connection.ts | 17 +- packages/product/src/loaders/container.ts | 34 +- .../migrations/.snapshot-medusa-products.json | 217 +++++++++- .../src/migrations/Migration20230609132805.ts | 57 --- .../src/migrations/Migration20230710091208.ts | 162 +++++++ packages/product/src/models/index.ts | 2 + .../product/src/models/product-category.ts | 2 +- .../product/src/models/product-collection.ts | 19 +- packages/product/src/models/product-image.ts | 46 ++ .../src/models/product-option-value.ts | 33 +- packages/product/src/models/product-option.ts | 17 +- packages/product/src/models/product-tag.ts | 11 +- packages/product/src/models/product-type.ts | 13 +- .../product/src/models/product-variant.ts | 22 +- packages/product/src/models/product.ts | 43 +- packages/product/src/module-definition.ts | 3 - packages/product/src/repositories/base.ts | 243 +++++++++++ packages/product/src/repositories/index.ts | 4 + .../src/repositories/product-category.ts | 105 +++-- .../src/repositories/product-collection.ts | 92 ++-- .../product/src/repositories/product-image.ts | 128 ++++++ .../src/repositories/product-option.ts | 90 ++++ .../product/src/repositories/product-tag.ts | 143 +++++-- .../product/src/repositories/product-type.ts | 135 ++++++ .../src/repositories/product-variant.ts | 100 +++-- packages/product/src/repositories/product.ts | 92 ++-- .../product/src/scripts/migration-down.ts | 15 +- packages/product/src/scripts/migration-up.ts | 15 +- packages/product/src/scripts/seed.ts | 15 +- .../src/services/__fixtures__/product.ts | 20 + .../src/services/__tests__/product.spec.ts | 206 ++++++--- packages/product/src/services/index.ts | 3 + .../product/src/services/product-category.ts | 83 +++- .../src/services/product-collection.ts | 58 ++- .../product/src/services/product-image.ts | 26 ++ .../src/services/product-module-service.ts | 396 ++++++++++++++++-- .../product/src/services/product-option.ts | 32 ++ packages/product/src/services/product-tag.ts | 42 +- packages/product/src/services/product-type.ts | 28 ++ .../product/src/services/product-variant.ts | 109 ++++- packages/product/src/services/product.ts | 112 ++++- packages/product/src/types/index.ts | 15 +- .../product/src/utils/create-connection.ts | 6 +- packages/product/src/utils/index.ts | 13 +- packages/product/src/utils/soft-deletable.ts | 23 + packages/types/package.json | 1 + packages/types/src/common/common.ts | 3 +- packages/types/src/dal/index.ts | 2 + packages/types/src/dal/repository-service.ts | 53 ++- packages/types/src/inventory/inventory.ts | 2 + packages/types/src/modules-sdk/index.ts | 15 + packages/types/src/product/common.ts | 134 +++++- packages/types/src/product/service.ts | 75 +++- packages/types/src/shared-context.ts | 6 + packages/utils/package.json | 4 +- packages/utils/src/bundles.ts | 8 +- packages/utils/src/common/deduplicate.ts | 3 + packages/utils/src/common/index.ts | 1 + .../utils/src/decorators/context-parameter.ts | 11 +- .../src/decorators/inject-entity-manager.ts | 17 +- packages/utils/src/index.ts | 3 +- .../__tests__/load-database-config.spec.ts | 22 +- .../src/modules-sdk/build-query.ts} | 28 +- .../utils/src/modules-sdk/decorators/index.ts | 1 + .../decorators/inject-transaction-manager.ts | 48 +++ packages/utils/src/modules-sdk/index.ts | 4 + .../load-module-database-config.ts} | 46 +- .../utils/src/modules-sdk/retrieve-entity.ts | 47 +++ yarn.lock | 168 +++++++- 98 files changed, 5444 insertions(+), 688 deletions(-) create mode 100644 .changeset/chilled-mice-deliver.md create mode 100644 .changeset/fresh-islands-teach.md create mode 100644 packages/medusa/src/workflows/admin/create-product/create-product.ts create mode 100644 packages/medusa/src/workflows/functions/attach-inventory-items.ts create mode 100644 packages/medusa/src/workflows/functions/create-inventory-items.ts create mode 100644 packages/medusa/src/workflows/functions/create-prducts.ts create mode 100644 packages/medusa/src/workflows/functions/index.ts create mode 100644 packages/medusa/src/workflows/functions/remove-inventory-items.ts create mode 100644 packages/medusa/src/workflows/functions/remove-products.ts create mode 100644 packages/product/integration-tests/__fixtures__/product/data/create-product.ts create mode 100644 packages/product/integration-tests/__fixtures__/variant/data/create-variant.ts create mode 100644 packages/product/integration-tests/__fixtures__/variant/index.ts create mode 100644 packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts create mode 100644 packages/product/integration-tests/__tests__/services/product-module-service/product-collections.spec.ts create mode 100644 packages/product/integration-tests/__tests__/services/product-module-service/product-variants.spec.ts delete mode 100644 packages/product/src/migrations/Migration20230609132805.ts create mode 100644 packages/product/src/migrations/Migration20230710091208.ts create mode 100644 packages/product/src/models/product-image.ts create mode 100644 packages/product/src/repositories/base.ts create mode 100644 packages/product/src/repositories/product-image.ts create mode 100644 packages/product/src/repositories/product-option.ts create mode 100644 packages/product/src/repositories/product-type.ts create mode 100644 packages/product/src/services/__fixtures__/product.ts create mode 100644 packages/product/src/services/product-image.ts create mode 100644 packages/product/src/services/product-option.ts create mode 100644 packages/product/src/services/product-type.ts create mode 100644 packages/product/src/utils/soft-deletable.ts create mode 100644 packages/utils/src/common/deduplicate.ts rename packages/{product/src/utils => utils/src/modules-sdk}/__tests__/load-database-config.spec.ts (84%) rename packages/{product/src/utils/query/index.ts => utils/src/modules-sdk/build-query.ts} (62%) create mode 100644 packages/utils/src/modules-sdk/decorators/index.ts create mode 100644 packages/utils/src/modules-sdk/decorators/inject-transaction-manager.ts create mode 100644 packages/utils/src/modules-sdk/index.ts rename packages/{product/src/utils/load-database-config.ts => utils/src/modules-sdk/load-module-database-config.ts} (51%) create mode 100644 packages/utils/src/modules-sdk/retrieve-entity.ts diff --git a/.changeset/chilled-mice-deliver.md b/.changeset/chilled-mice-deliver.md new file mode 100644 index 0000000000..0456417fa9 --- /dev/null +++ b/.changeset/chilled-mice-deliver.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +"@medusajs/product": patch +--- + +Feat(product): product module create - delete - soft delete - restore - create workflow diff --git a/.changeset/fresh-islands-teach.md b/.changeset/fresh-islands-teach.md new file mode 100644 index 0000000000..9462fe53e6 --- /dev/null +++ b/.changeset/fresh-islands-teach.md @@ -0,0 +1,6 @@ +--- +"@medusajs/product": patch +"@medusajs/types": patch +--- + +chore: added collection methods for module and collection service diff --git a/.github/workflows/action.yml b/.github/workflows/action.yml index 25a61c8ab3..b62bf3896a 100644 --- a/.github/workflows/action.yml +++ b/.github/workflows/action.yml @@ -65,6 +65,68 @@ jobs: - name: Run unit tests run: yarn test + integration-tests-packages: + needs: setup + runs-on: ubuntu-latest + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 1s + --health-timeout 10s + --health-retries 10 + ports: + - 6379:6379 + postgres: + image: postgres + env: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + options: >- + --health-cmd pg_isready + --health-interval 1s + --health-timeout 10s + --health-retries 10 + ports: + - 5432:5432 + + steps: + - name: Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + with: + access_token: ${{ github.token }} + + - name: Checkout + uses: actions/checkout@v2.3.5 + with: + fetch-depth: 0 + + - name: Setup Node.js environment + uses: actions/setup-node@v2.4.1 + with: + node-version: "16" + cache: "yarn" + + - name: Install dependencies + uses: ./.github/actions/cache-deps + with: + extension: pipeline + + - name: Build Packages + run: yarn build + + - name: Run integration tests + run: yarn test:integration:packages + env: + DB_PASSWORD: postgres + DB_USERNAME: postgres + SPLIT: ${{ steps['split-tests'].outputs['split'] }} + integration-tests-api: needs: setup runs-on: ubuntu-latest diff --git a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js index 755c2e01e4..7cbd4745d8 100644 --- a/integration-tests/plugins/__tests__/inventory/inventory-items/index.js +++ b/integration-tests/plugins/__tests__/inventory/inventory-items/index.js @@ -569,17 +569,19 @@ describe("Inventory Items endpoints", () => { ]) ) expect(response.data.inventory_items).toHaveLength(3) - expect(response.data.inventory_items).toEqual([ - expect.objectContaining({ - sku: "Test Sku", - }), - expect.objectContaining({ - description: "Test Desc", - }), - expect.objectContaining({ - title: "Test Item", - }), - ]) + expect(response.data.inventory_items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sku: "Test Sku", + }), + expect.objectContaining({ + description: "Test Desc", + }), + expect.objectContaining({ + title: "Test Item", + }), + ]) + ) }) }) diff --git a/package.json b/package.json index ac018a5a48..78a99588bf 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "prettier": "prettier", "jest": "jest", "test": "turbo run test --no-daemon", - "test:integration": "turbo run test:integration --no-daemon --filter='./packages/*'", + "test:integration:packages": "turbo run test:integration --no-daemon --filter='./packages/*'", "test:integration:api": "turbo run test:integration --no-daemon --filter=integration-tests-api", "test:integration:plugins": "turbo run test:integration --no-daemon --filter=integration-tests-plugins", "test:integration:repositories": "turbo run test:integration --no-daemon --filter=integration-tests-repositories", diff --git a/packages/medusa/src/workflows/admin/create-product/create-product.ts b/packages/medusa/src/workflows/admin/create-product/create-product.ts new file mode 100644 index 0000000000..2354a3059c --- /dev/null +++ b/packages/medusa/src/workflows/admin/create-product/create-product.ts @@ -0,0 +1,159 @@ +import { EntityManager } from "typeorm" +import { + IInventoryService, + MedusaContainer, + ProductTypes, +} from "@medusajs/types" +import { ulid } from "ulid" +import { MedusaError } from "@medusajs/utils" +import { + DistributedTransaction, + TransactionHandlerType, + TransactionOrchestrator, + TransactionPayload, + TransactionState, + TransactionStepsDefinition, +} from "../../../utils/transaction" +import { CreateProductVariantInput } from "../../../types/product-variant" +import { + attachInventoryItems, + createInventoryItems, + createProducts, + removeInventoryItems, + removeProducts, +} from "../../functions" + +enum Actions { + createProduct = "createProduct", + createPrices = "createPrices", + attachToSalesChannel = "attachToSalesChannel", + createInventoryItems = "createInventoryItems", + attachInventoryItems = "attachInventoryItems", +} + +const workflowSteps: TransactionStepsDefinition = { + next: { + action: Actions.createProduct, + saveResponse: true, + next: { + action: Actions.attachToSalesChannel, + saveResponse: true, + next: { + action: Actions.createPrices, + saveResponse: true, + next: { + action: Actions.createInventoryItems, + saveResponse: true, + next: { + action: Actions.attachInventoryItems, + noCompensation: true, + }, + }, + }, + }, + }, +} + +const createProductOrchestrator = new TransactionOrchestrator( + "create-product", + workflowSteps +) + +type InjectedDependencies = { + manager: EntityManager + container: MedusaContainer + inventoryService?: IInventoryService +} + +export async function createProductWorkflow( + dependencies: InjectedDependencies, + productId: string, + input: CreateProductVariantInput[] +): Promise { + const { manager, container } = dependencies + async function transactionHandler( + actionId: string, + type: TransactionHandlerType, + payload: TransactionPayload + ) { + const command = { + [Actions.createProduct]: { + [TransactionHandlerType.INVOKE]: async ( + data: ProductTypes.CreateProductDTO[] + ) => { + return await createProducts({ + container, + data, + }) + }, + [TransactionHandlerType.COMPENSATE]: async ( + data: any[], + { invoke } + ) => { + const createdProducts = invoke[Actions.createProduct] + return await removeProducts({ container, data: createdProducts }) + }, + }, + [Actions.createInventoryItems]: { + [TransactionHandlerType.INVOKE]: async ( + data: CreateProductVariantInput[], + { invoke } + ) => { + const { [Actions.createProduct]: products } = invoke + + return await createInventoryItems({ + container, + manager, + data: products, + }) + }, + [TransactionHandlerType.COMPENSATE]: async (_, { invoke }) => { + const variantInventoryItemsData = invoke[Actions.createInventoryItems] + await removeInventoryItems({ + container, + manager, + data: variantInventoryItemsData, + }) + }, + }, + [Actions.attachInventoryItems]: { + [TransactionHandlerType.INVOKE]: async ( + data: CreateProductVariantInput[], + { invoke } + ) => { + const { [Actions.createInventoryItems]: inventoryItemsResult } = + invoke + + return await attachInventoryItems({ + container, + manager, + data: inventoryItemsResult, + }) + }, + }, + } + return command[actionId][type](payload.data, payload.context) + } + + const orchestrator = createProductOrchestrator + + const transaction = await orchestrator.beginTransaction( + ulid(), + transactionHandler, + input + ) + + await orchestrator.resume(transaction) + + if (transaction.getState() !== TransactionState.DONE) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + transaction + .getErrors() + .map((err) => err.error?.message) + .join("\n") + ) + } + + return transaction +} diff --git a/packages/medusa/src/workflows/functions/attach-inventory-items.ts b/packages/medusa/src/workflows/functions/attach-inventory-items.ts new file mode 100644 index 0000000000..0659850a69 --- /dev/null +++ b/packages/medusa/src/workflows/functions/attach-inventory-items.ts @@ -0,0 +1,34 @@ +import { + InventoryItemDTO, + MedusaContainer, + ProductTypes, +} from "@medusajs/types" +import { EntityManager } from "typeorm" + +export async function attachInventoryItems({ + container, + manager, + data, +}: { + container: MedusaContainer + manager: EntityManager + data: { + variant: ProductTypes.ProductVariantDTO + inventoryItem: InventoryItemDTO + }[] +}) { + const productVariantInventoryService = container + .resolve("productVariantInventoryService") + .withTransaction(manager) + + return await Promise.all( + data + .filter((d) => d) + .map(async ({ variant, inventoryItem }) => { + return await productVariantInventoryService.attachInventoryItem( + variant.id, + inventoryItem.id + ) + }) + ) +} diff --git a/packages/medusa/src/workflows/functions/create-inventory-items.ts b/packages/medusa/src/workflows/functions/create-inventory-items.ts new file mode 100644 index 0000000000..9f7d666085 --- /dev/null +++ b/packages/medusa/src/workflows/functions/create-inventory-items.ts @@ -0,0 +1,55 @@ +import { + IInventoryService, + MedusaContainer, + ProductTypes, +} from "@medusajs/types" +import { EntityManager } from "typeorm" + +export async function createInventoryItems({ + container, + manager, + data, +}: { + container: MedusaContainer + manager: EntityManager + data: ProductTypes.ProductDTO[] +}) { + const inventoryService: IInventoryService = + container.resolve("inventoryService") + const context = { transactionManager: manager } + + const variants = data.reduce( + ( + acc: ProductTypes.ProductVariantDTO[], + product: ProductTypes.ProductDTO + ) => { + return acc.concat(product.variants) + }, + [] + ) + + return await Promise.all( + variants.map(async (variant) => { + if (!variant.manage_inventory) { + return + } + + const inventoryItem = await inventoryService!.createInventoryItem( + { + sku: variant.sku!, + origin_country: variant.origin_country!, + hs_code: variant.hs_code!, + mid_code: variant.mid_code!, + material: variant.material!, + weight: variant.weight!, + length: variant.length!, + height: variant.height!, + width: variant.width!, + }, + context + ) + + return { variant, inventoryItem } + }) + ) +} diff --git a/packages/medusa/src/workflows/functions/create-prducts.ts b/packages/medusa/src/workflows/functions/create-prducts.ts new file mode 100644 index 0000000000..4af2fea25c --- /dev/null +++ b/packages/medusa/src/workflows/functions/create-prducts.ts @@ -0,0 +1,12 @@ +import { MedusaContainer, ProductTypes } from "@medusajs/types" + +export async function removeProducts({ + container, + data, +}: { + container: MedusaContainer + data: ProductTypes.ProductDTO[] +}): Promise { + const productModuleService = container.resolve("productModuleService") + return await productModuleService.softDelete(data.map((p) => p.id)) +} diff --git a/packages/medusa/src/workflows/functions/index.ts b/packages/medusa/src/workflows/functions/index.ts new file mode 100644 index 0000000000..6a4a8a48af --- /dev/null +++ b/packages/medusa/src/workflows/functions/index.ts @@ -0,0 +1,5 @@ +export * from "./create-prducts" +export * from "./remove-products" +export * from "./create-inventory-items" +export * from "./remove-inventory-items" +export * from "./attach-inventory-items" diff --git a/packages/medusa/src/workflows/functions/remove-inventory-items.ts b/packages/medusa/src/workflows/functions/remove-inventory-items.ts new file mode 100644 index 0000000000..1ec59b7a52 --- /dev/null +++ b/packages/medusa/src/workflows/functions/remove-inventory-items.ts @@ -0,0 +1,26 @@ +import { InventoryItemDTO, MedusaContainer } from "@medusajs/types" +import { EntityManager } from "typeorm" + +export async function removeInventoryItems({ + container, + manager, + data, +}: { + container: MedusaContainer + manager: EntityManager + data: { + inventoryItem: InventoryItemDTO + }[] +}) { + const inventoryService = container.resolve("inventoryService") + const context = { transactionManager: manager } + + return await Promise.all( + data.map(async ({ inventoryItem }) => { + return await inventoryService!.deleteInventoryItem( + inventoryItem.id, + context + ) + }) + ) +} diff --git a/packages/medusa/src/workflows/functions/remove-products.ts b/packages/medusa/src/workflows/functions/remove-products.ts new file mode 100644 index 0000000000..926cded0f8 --- /dev/null +++ b/packages/medusa/src/workflows/functions/remove-products.ts @@ -0,0 +1,12 @@ +import { MedusaContainer, ProductTypes } from "@medusajs/types" + +export async function createProducts({ + container, + data, +}: { + container: MedusaContainer + data: ProductTypes.CreateProductDTO[] +}) { + const productModuleService = container.resolve("productModuleService") + return await productModuleService.create(data) +} diff --git a/packages/product/integration-tests/__fixtures__/module.ts b/packages/product/integration-tests/__fixtures__/module.ts index c9c1b18522..017c3cbaf8 100644 --- a/packages/product/integration-tests/__fixtures__/module.ts +++ b/packages/product/integration-tests/__fixtures__/module.ts @@ -1,14 +1,9 @@ -import { FindOptions, RepositoryService } from "@medusajs/types" +import { BaseRepository } from "../../src/repositories/base" -class CustomRepository implements RepositoryService { - constructor() {} - - find(options?: FindOptions): Promise { - throw new Error("Method not implemented.") - } - - findAndCount(options?: FindOptions): Promise<[any[], number]> { - throw new Error("Method not implemented.") +class CustomRepository extends BaseRepository { + constructor({ manager }) { + // @ts-ignore + super(...arguments) } } diff --git a/packages/product/integration-tests/__fixtures__/product/data/create-product.ts b/packages/product/integration-tests/__fixtures__/product/data/create-product.ts new file mode 100644 index 0000000000..ed39ec248c --- /dev/null +++ b/packages/product/integration-tests/__fixtures__/product/data/create-product.ts @@ -0,0 +1,80 @@ +import { ProductTypes } from "@medusajs/types" +import faker from "faker" +import { Image } from "@models" + +export const buildProductOnlyData = ({ + title, + description, + subtitle, + is_giftcard, + discountable, + thumbnail, + images, + status, +}: { + title?: string + description?: string + subtitle?: string + is_giftcard?: boolean + discountable?: boolean + thumbnail?: string + images?: { id?: string; url: string }[] + status?: ProductTypes.ProductStatus +} = {}) => { + return { + title: title ?? faker.commerce.productName(), + description: description ?? faker.commerce.productName(), + subtitle: subtitle ?? faker.commerce.productName(), + is_giftcard: is_giftcard ?? false, + discountable: discountable ?? true, + thumbnail: thumbnail as string, + status: status ?? ProductTypes.ProductStatus.PUBLISHED, + images: (images ?? []) as Image[], + } +} + +export const buildProductAndRelationsData = ({ + title, + description, + subtitle, + is_giftcard, + discountable, + thumbnail, + images, + status, + type, + tags, + options, + variants, +}: Partial) => { + const defaultOptionTitle = faker.commerce.productName() + return { + title: title ?? faker.commerce.productName(), + description: description ?? faker.commerce.productName(), + subtitle: subtitle ?? faker.commerce.productName(), + is_giftcard: is_giftcard ?? false, + discountable: discountable ?? true, + thumbnail: thumbnail as string, + status: status ?? ProductTypes.ProductStatus.PUBLISHED, + images: (images ?? []) as Image[], + type: type ? { value: type } : { value: faker.commerce.productName() }, + tags: tags ?? [{ value: "tag-1" }], + options: options ?? [ + { + title: defaultOptionTitle, + }, + ], + variants: variants ?? [ + { + title: faker.commerce.productName(), + sku: faker.commerce.productName(), + options: [ + { + value: defaultOptionTitle + faker.commerce.productName(), + }, + ], + }, + ], + // TODO: add categories, must be created first + } +} diff --git a/packages/product/integration-tests/__fixtures__/product/index.ts b/packages/product/integration-tests/__fixtures__/product/index.ts index 1fa7158c28..1e7d1f98dc 100644 --- a/packages/product/integration-tests/__fixtures__/product/index.ts +++ b/packages/product/integration-tests/__fixtures__/product/index.ts @@ -1,5 +1,7 @@ +import { ProductTypes } from "@medusajs/types" import { SqlEntityManager } from "@mikro-orm/postgresql" import { + Image, Product, ProductCategory, ProductCollection, @@ -7,9 +9,16 @@ import { } from "@models" import ProductOption from "../../../src/models/product-option" +export * from "./data/create-product" + export async function createProductAndTags( manager: SqlEntityManager, - data: any[] + data: { + id?: string + title: string + status: ProductTypes.ProductStatus + tags?: { id: string; value: string }[] + }[] ) { const products: any[] = data.map((productData) => { return manager.create(Product, productData) @@ -35,7 +44,11 @@ export async function createProductVariants( export async function createCollections( manager: SqlEntityManager, - collectionData: any[] + collectionData: { + id?: string + title: string + handle?: string + }[] ) { const collections: any[] = collectionData.map((collectionData) => { return manager.create(ProductCollection, collectionData) @@ -48,10 +61,21 @@ export async function createCollections( export async function createOptions( manager: SqlEntityManager, - optionsData: any[] + optionsData: { + id?: string + product: { id: string } + title: string + value?: string + values?: { + id?: string + value: string + variant?: { id: string } & any + }[] + variant?: { id: string } & any + }[] ) { - const options: any[] = optionsData.map((o) => { - return manager.create(ProductOption, o) + const options: any[] = optionsData.map((option) => { + return manager.create(ProductOption, option) }) await manager.persistAndFlush(options) @@ -59,6 +83,19 @@ export async function createOptions( return options } +export async function createImages( + manager: SqlEntityManager, + imagesData: string[] +) { + const images: any[] = imagesData.map((img) => { + return manager.create(Image, { url: img }) + }) + + await manager.persistAndFlush(images) + + return images +} + export async function assignCategoriesToProduct( manager: SqlEntityManager, product: Product, diff --git a/packages/product/integration-tests/__fixtures__/variant/data/create-variant.ts b/packages/product/integration-tests/__fixtures__/variant/data/create-variant.ts new file mode 100644 index 0000000000..fd1c70e4c9 --- /dev/null +++ b/packages/product/integration-tests/__fixtures__/variant/data/create-variant.ts @@ -0,0 +1,44 @@ +import { ProductTypes } from "@medusajs/types" +import faker from "faker" + +export const buildProductVariantOnlyData = ({ + title, + sku, + barcode, + ean, + upc, + allow_backorder, + inventory_quantity, + manage_inventory, + hs_code, + origin_country, + mid_code, + material, + weight, + length, + height, + width, + options, + metadata, +}: Partial) => { + return { + title: title ?? faker.commerce.productName(), + sku: sku ?? faker.commerce.productName(), + barcode, + ean, + upc, + allow_backorder, + inventory_quantity, + manage_inventory, + hs_code, + origin_country, + mid_code, + material, + weight, + length, + height, + width, + options, + metadata, + } +} diff --git a/packages/product/integration-tests/__fixtures__/variant/index.ts b/packages/product/integration-tests/__fixtures__/variant/index.ts new file mode 100644 index 0000000000..04d52604a2 --- /dev/null +++ b/packages/product/integration-tests/__fixtures__/variant/index.ts @@ -0,0 +1 @@ +export * from "./data/create-variant" diff --git a/packages/product/integration-tests/__tests__/module.ts b/packages/product/integration-tests/__tests__/module.ts index f7a38aca6d..7e1e27adc1 100644 --- a/packages/product/integration-tests/__tests__/module.ts +++ b/packages/product/integration-tests/__tests__/module.ts @@ -1,11 +1,13 @@ import { MedusaModule } from "@medusajs/modules-sdk" -import { Product } from "@models" import { initialize } from "../../src" import * as CustomRepositories from "../__fixtures__/module" import { ProductRepository } from "../__fixtures__/module" import { createProductAndTags } from "../__fixtures__/product" import { productsData } from "../__fixtures__/product/data" import { DB_URL, TestDatabase } from "../utils" +import { buildProductAndRelationsData } from "../__fixtures__/product/data/create-product" +import { kebabCase } from "@medusajs/utils" +import { IProductModuleService } from "@medusajs/types" const beforeEach_ = async () => { await TestDatabase.setupDatabase() @@ -18,12 +20,11 @@ const afterEach_ = async () => { describe("Product module", function () { describe("Using built-in data access layer", function () { - let module - let products: Product[] + let module: IProductModuleService beforeEach(async () => { const testManager = await beforeEach_() - products = await createProductAndTags(testManager, productsData) + await createProductAndTags(testManager, productsData) module = await initialize({ database: { @@ -46,13 +47,12 @@ describe("Product module", function () { }) describe("Using custom data access layer", function () { - let module - let products: Product[] + let module: IProductModuleService beforeEach(async () => { const testManager = await beforeEach_() - products = await createProductAndTags(testManager, productsData) + await createProductAndTags(testManager, productsData) module = await initialize({ database: { @@ -78,12 +78,11 @@ describe("Product module", function () { }) describe("Using custom data access layer and connection", function () { - let module - let products: Product[] + let module: IProductModuleService beforeEach(async () => { const testManager = await beforeEach_() - products = await createProductAndTags(testManager, productsData) + await createProductAndTags(testManager, productsData) MedusaModule.clearInstances() @@ -106,4 +105,244 @@ describe("Product module", function () { expect(products).toHaveLength(0) }) }) + + describe("create", function () { + let module: IProductModuleService + let images = ["image-1"] + + beforeEach(async () => { + await beforeEach_() + + MedusaModule.clearInstances() + + module = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA, + }, + }) + }) + + afterEach(afterEach_) + + it("should create a product", async () => { + const data = buildProductAndRelationsData({ + images, + thumbnail: images[0], + }) + + const products = await module.create([data]) + + expect(products).toHaveLength(1) + + expect(products[0].images).toHaveLength(1) + expect(products[0].options).toHaveLength(1) + expect(products[0].tags).toHaveLength(1) + expect(products[0].categories).toHaveLength(0) + expect(products[0].variants).toHaveLength(1) + + expect(products[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + title: data.title, + handle: kebabCase(data.title), + description: data.description, + subtitle: data.subtitle, + is_giftcard: data.is_giftcard, + discountable: data.discountable, + thumbnail: images[0], + status: data.status, + images: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + url: images[0], + }), + ]), + options: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + title: data.options[0].title, + values: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + value: data.variants[0].options?.[0].value, + }), + ]), + }), + ]), + tags: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + value: data.tags[0].value, + }), + ]), + type: expect.objectContaining({ + id: expect.any(String), + value: data.type.value, + }), + variants: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + title: data.variants[0].title, + sku: data.variants[0].sku, + allow_backorder: false, + manage_inventory: true, + inventory_quantity: 100, + variant_rank: 0, + options: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + value: data.variants[0].options?.[0].value, + }), + ]), + }), + ]), + }) + ) + }) + }) + + describe("softDelete", function () { + let module: IProductModuleService + let images = ["image-1"] + + beforeEach(async () => { + await beforeEach_() + + MedusaModule.clearInstances() + + module = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA, + }, + }) + }) + + afterEach(afterEach_) + + it("should soft delete a product and its cascaded relations", async () => { + const data = buildProductAndRelationsData({ + images, + thumbnail: images[0], + }) + + const products = await module.create([data]) + + await module.softDelete([products[0].id]) + + const deletedProducts = await module.list( + { id: products[0].id }, + { + relations: [ + "variants", + "variants.options", + "options", + "options.values", + ], + withDeleted: true, + } + ) + + expect(deletedProducts).toHaveLength(1) + expect(deletedProducts[0].deleted_at).not.toBeNull() + + for (const option of deletedProducts[0].options) { + expect(option.deleted_at).not.toBeNull() + } + + const productOptionsValues = deletedProducts[0].options + .map((o) => o.values) + .flat() + + for (const optionValue of productOptionsValues) { + expect(optionValue.deleted_at).not.toBeNull() + } + + for (const variant of deletedProducts[0].variants) { + expect(variant.deleted_at).not.toBeNull() + } + + const variantsOptions = deletedProducts[0].options + .map((o) => o.values) + .flat() + + for (const option of variantsOptions) { + expect(option.deleted_at).not.toBeNull() + } + }) + }) + + describe("restore", function () { + let module: IProductModuleService + let images = ["image-1"] + + beforeEach(async () => { + await beforeEach_() + + MedusaModule.clearInstances() + + module = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA, + }, + }) + }) + + afterEach(afterEach_) + + it("should restore a soft deleted product and its cascaded relations", async () => { + const data = buildProductAndRelationsData({ + images, + thumbnail: images[0], + }) + + const products = await module.create([data]) + + await module.softDelete([products[0].id]) + await module.restore([products[0].id]) + + const deletedProducts = await module.list( + { id: products[0].id }, + { + relations: [ + "variants", + "variants.options", + "variants.options", + "options", + "options.values", + ], + withDeleted: true, + } + ) + + expect(deletedProducts).toHaveLength(1) + expect(deletedProducts[0].deleted_at).toBeNull() + + for (const option of deletedProducts[0].options) { + expect(option.deleted_at).toBeNull() + } + + const productOptionsValues = deletedProducts[0].options + .map((o) => o.values) + .flat() + + for (const optionValue of productOptionsValues) { + expect(optionValue.deleted_at).toBeNull() + } + + for (const variant of deletedProducts[0].variants) { + expect(variant.deleted_at).toBeNull() + } + + const variantsOptions = deletedProducts[0].options + .map((o) => o.values) + .flat() + + for (const option of variantsOptions) { + expect(option.deleted_at).toBeNull() + } + }) + }) }) diff --git a/packages/product/integration-tests/__tests__/services/product-category/index.ts b/packages/product/integration-tests/__tests__/services/product-category/index.ts index 89bd83881b..c5878466fe 100644 --- a/packages/product/integration-tests/__tests__/services/product-category/index.ts +++ b/packages/product/integration-tests/__tests__/services/product-category/index.ts @@ -10,7 +10,7 @@ import { productCategoriesData } from "../../../__fixtures__/product-category/da jest.setTimeout(30000) -describe("ProductCategory Service", () => { +describe("Product category Service", () => { let service: ProductCategoryService let testManager: SqlEntityManager let repositoryManager: SqlEntityManager @@ -47,7 +47,7 @@ describe("ProductCategory Service", () => { const productCategoryResults = await service.list( {}, { - select: ["id", "parent_category_id"] as any, + select: ["id", "parent_category_id"], } ) @@ -128,7 +128,7 @@ describe("ProductCategory Service", () => { include_descendants_tree: true, }, { - select: ["id", "handle"] as any, + select: ["id", "handle"], } ) @@ -192,7 +192,7 @@ describe("ProductCategory Service", () => { is_internal: false, }, { - select: ["id", "handle"] as any, + select: ["id", "handle"], } ) @@ -230,4 +230,334 @@ describe("ProductCategory Service", () => { ]) }) }) + + describe("retrieve", () => { + const categoryOneId = "category-1" + + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + productCategories = await createProductCategories( + testManager, + productCategoriesData + ) + }) + + it("should return category for the given id", async () => { + const productCategoryResults = await service.retrieve( + categoryOneId, + ) + + expect(productCategoryResults).toEqual( + expect.objectContaining({ + id: categoryOneId + }) + ) + }) + + it("should throw an error when category with id does not exist", async () => { + let error + + try { + await service.retrieve("does-not-exist") + } catch (e) { + error = e + } + + expect(error.message).toEqual('ProductCategory with id: does-not-exist was not found') + }) + + it("should throw an error when an id is not provided", async () => { + let error + + try { + await service.retrieve(undefined as unknown as string) + } catch (e) { + error = e + } + + expect(error.message).toEqual('"productCategoryId" must be defined') + }) + + it("should return category based on config select param", async () => { + const productCategoryResults = await service.retrieve( + categoryOneId, + { + select: ["id", "parent_category_id"], + } + ) + + expect(productCategoryResults).toEqual( + expect.objectContaining({ + id: categoryOneId, + parent_category_id: "category-0", + }) + ) + }) + + it("should return category based on config relation param", async () => { + const productCategoryResults = await service.retrieve( + categoryOneId, + { + select: ["id", "parent_category_id"], + relations: ["parent_category"] + } + ) + + expect(productCategoryResults).toEqual( + expect.objectContaining({ + id: categoryOneId, + category_children: [ + expect.objectContaining({ + id: 'category-1-a', + }), + expect.objectContaining({ + id: 'category-1-b', + }) + ], + parent_category: expect.objectContaining({ + id: "category-0" + }) + }) + ) + }) + }) + + describe("listAndCount", () => { + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + productCategories = await createProductCategories( + testManager, + productCategoriesData + ) + }) + + it("should return categories and count based on take and skip", async () => { + let results = await service.listAndCount( + {}, + { + take: 1, + } + ) + + expect(results[1]).toEqual(5) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: "category-0", + }), + ]) + + results = await service.listAndCount( + {}, + { + take: 1, + skip: 1 + } + ) + + expect(results[1]).toEqual(5) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: "category-1", + }), + ]) + }) + + it("should return all product categories and count", async () => { + const productCategoryResults = await service.listAndCount( + {}, + { + select: ["id", "parent_category_id"], + relations: ["parent_category"], + } + ) + + expect(productCategoryResults[1]).toEqual(5) + expect(productCategoryResults[0]).toEqual([ + expect.objectContaining({ + id: "category-0", + parent_category: null + }), + expect.objectContaining({ + id: "category-1", + parent_category: expect.objectContaining({ + id: "category-0", + }), + }), + expect.objectContaining({ + id: "category-1-a", + parent_category: expect.objectContaining({ + id: "category-1", + }), + }), + expect.objectContaining({ + id: "category-1-b", + parent_category: expect.objectContaining({ + id: "category-1", + }), + }), + expect.objectContaining({ + id: "category-1-b-1", + parent_category: expect.objectContaining({ + id: "category-1-b", + }), + }), + ]) + }) + + it("should only return categories that are scoped by parent_category_id", async () => { + let productCategoryResults = await service.listAndCount( + { parent_category_id: null }, + { + select: ["id"], + } + ) + + expect(productCategoryResults[1]).toEqual(1) + expect(productCategoryResults[0]).toEqual([ + expect.objectContaining({ + id: "category-0", + }), + ]) + + productCategoryResults = await service.listAndCount({ + parent_category_id: "category-0", + }) + + expect(productCategoryResults[1]).toEqual(1) + expect(productCategoryResults[0]).toEqual([ + expect.objectContaining({ + id: "category-1", + }), + ]) + + productCategoryResults = await service.listAndCount({ + parent_category_id: ["category-1-b", "category-0"], + }) + + expect(productCategoryResults[1]).toEqual(2) + expect(productCategoryResults[0]).toEqual([ + expect.objectContaining({ + id: "category-1", + }), + expect.objectContaining({ + id: "category-1-b-1", + }), + ]) + }) + + it("should includes descendants when include_descendants_tree is true", async () => { + const productCategoryResults = await service.listAndCount( + { + parent_category_id: null, + include_descendants_tree: true, + }, + { + select: ["id", "handle"], + } + ) + + expect(productCategoryResults[1]).toEqual(1) + + const serializedObject = JSON.parse( + JSON.stringify(productCategoryResults[0]) + ) + + expect(serializedObject).toEqual([ + expect.objectContaining({ + id: "category-0", + handle: "category-0", + mpath: "category-0.", + parent_category_id: null, + parent_category: null, + category_children: [ + expect.objectContaining({ + id: "category-1", + handle: "category-1", + mpath: "category-0.category-1.", + parent_category_id: "category-0", + parent_category: "category-0", + category_children: [ + expect.objectContaining({ + id: "category-1-a", + handle: "category-1-a", + mpath: "category-0.category-1.category-1-a.", + parent_category_id: "category-1", + parent_category: "category-1", + category_children: [], + }), + expect.objectContaining({ + id: "category-1-b", + handle: "category-1-b", + mpath: "category-0.category-1.category-1-b.", + parent_category_id: "category-1", + parent_category: "category-1", + category_children: [ + expect.objectContaining({ + id: "category-1-b-1", + handle: "category-1-b-1", + mpath: + "category-0.category-1.category-1-b.category-1-b-1.", + parent_category_id: "category-1-b", + parent_category: "category-1-b", + category_children: [], + }), + ], + }), + ], + }), + ], + }), + ]) + }) + + it("should filter out children when include_descendants_tree is true", async () => { + const productCategoryResults = await service.listAndCount( + { + parent_category_id: null, + include_descendants_tree: true, + is_internal: false, + }, + { + select: ["id", "handle"], + } + ) + + expect(productCategoryResults[1]).toEqual(1) + + const serializedObject = JSON.parse( + JSON.stringify(productCategoryResults[0]) + ) + + expect(serializedObject).toEqual([ + expect.objectContaining({ + id: "category-0", + handle: "category-0", + mpath: "category-0.", + parent_category_id: null, + parent_category: null, + category_children: [ + expect.objectContaining({ + id: "category-1", + handle: "category-1", + mpath: "category-0.category-1.", + parent_category_id: "category-0", + parent_category: "category-0", + category_children: [ + expect.objectContaining({ + id: "category-1-a", + handle: "category-1-a", + mpath: "category-0.category-1.category-1-a.", + parent_category_id: "category-1", + parent_category: "category-1", + category_children: [], + }), + ], + }), + ], + }), + ]) + }) + }) }) diff --git a/packages/product/integration-tests/__tests__/services/product-collection/index.ts b/packages/product/integration-tests/__tests__/services/product-collection/index.ts index a8b64aaaea..1659599452 100644 --- a/packages/product/integration-tests/__tests__/services/product-collection/index.ts +++ b/packages/product/integration-tests/__tests__/services/product-collection/index.ts @@ -9,7 +9,7 @@ import { createCollections } from "../../../__fixtures__/product" jest.setTimeout(30000) -describe("Product Service", () => { +describe("Product collection Service", () => { let service: ProductCollectionService let testManager: SqlEntityManager let repositoryManager: SqlEntityManager @@ -59,9 +59,9 @@ describe("Product Service", () => { }) it("list product collections", async () => { - const tagsResults = await service.list() + const productCollectionResults = await service.list() - expect(tagsResults).toEqual([ + expect(productCollectionResults).toEqual([ expect.objectContaining({ id: "test-1", title: "col 1", @@ -82,9 +82,9 @@ describe("Product Service", () => { }) it("list product collections by id", async () => { - const tagsResults = await service.list({ id: data![0].id }) + const productCollectionResults = await service.list({ id: data![0].id }) - expect(tagsResults).toEqual([ + expect(productCollectionResults).toEqual([ expect.objectContaining({ id: "test-1", title: "col 1", @@ -93,9 +93,9 @@ describe("Product Service", () => { }) it("list product collections by title matching string", async () => { - const tagsResults = await service.list({ title: "col 3 extra" }) + const productCollectionResults = await service.list({ title: "col 3 extra" }) - expect(tagsResults).toEqual([ + expect(productCollectionResults).toEqual([ expect.objectContaining({ id: "test-3", title: "col 3 extra", @@ -103,4 +103,174 @@ describe("Product Service", () => { ]) }) }) + + describe("listAndCount", () => { + const data = [ + { + id: "test-1", + title: "col 1", + }, + { + id: "test-2", + title: "col 2", + }, + { + id: "test-3", + title: "col 3 extra", + }, + { + id: "test-4", + title: "col 4 extra", + }, + ] + + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + collectionsData = await createCollections(testManager, data) + }) + + it("should return all collections and count", async () => { + const [productCollectionResults, count] = await service.listAndCount() + const serialized = JSON.parse(JSON.stringify(productCollectionResults)) + + expect(serialized).toEqual([ + expect.objectContaining({ + id: "test-1", + title: "col 1", + }), + expect.objectContaining({ + id: "test-2", + title: "col 2", + }), + expect.objectContaining({ + id: "test-3", + title: "col 3 extra", + }), + expect.objectContaining({ + id: "test-4", + title: "col 4 extra", + }), + ]) + }) + + it("should return count and collections based on filter data", async () => { + const [productCollectionResults, count] = await service.listAndCount({ id: data![0].id }) + const serialized = JSON.parse(JSON.stringify(productCollectionResults)) + + expect(count).toEqual(1) + expect(serialized).toEqual([ + expect.objectContaining({ + id: "test-1", + title: "col 1", + }), + ]) + }) + + it("should return count and collections based on config data", async () => { + const [productCollectionResults, count] = await service.listAndCount({}, { + relations: ['products'], + select: ['title'], + take: 1, + skip: 1, + }) + const serialized = JSON.parse(JSON.stringify(productCollectionResults)) + + expect(count).toEqual(4) + expect(serialized).toEqual([ + { + id: "test-2", + title: "col 2", + products: [] + }, + ]) + }) + }) + + describe("retrieve", () => { + const collectionData = { + id: "collection-1", + title: "collection 1", + } + + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + await createCollections(testManager, [collectionData]) + }) + + it("should return collection for the given id", async () => { + const productCollectionResults = await service.retrieve( + collectionData.id, + ) + + expect(productCollectionResults).toEqual( + expect.objectContaining({ + id: collectionData.id + }) + ) + }) + + it("should throw an error when collection with id does not exist", async () => { + let error + + try { + await service.retrieve("does-not-exist") + } catch (e) { + error = e + } + + expect(error.message).toEqual('ProductCollection with id: does-not-exist was not found') + }) + + it("should throw an error when an id is not provided", async () => { + let error + + try { + await service.retrieve(undefined as unknown as string) + } catch (e) { + error = e + } + + expect(error.message).toEqual('"productCollectionId" must be defined') + }) + + it("should return collection based on config select param", async () => { + const productCollectionResults = await service.retrieve( + collectionData.id, + { + select: ["id", "title"], + } + ) + + const serialized = JSON.parse(JSON.stringify(productCollectionResults)) + + expect(serialized).toEqual( + { + id: collectionData.id, + title: collectionData.title, + } + ) + }) + + it("should return collection based on config relation param", async () => { + const productCollectionResults = await service.retrieve( + collectionData.id, + { + select: ["id", "title"], + relations: ["products"] + } + ) + + const serialized = JSON.parse(JSON.stringify(productCollectionResults)) + + expect(serialized).toEqual( + { + id: collectionData.id, + title: collectionData.title, + products: [] + } + ) + }) + }) }) diff --git a/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts b/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts new file mode 100644 index 0000000000..20ba70618f --- /dev/null +++ b/packages/product/integration-tests/__tests__/services/product-module-service/product-categories.spec.ts @@ -0,0 +1,248 @@ +import { IProductModuleService } from "@medusajs/types" +import { Product, ProductCategory } from "@models" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { ProductTypes } from "@medusajs/types" + +import { initialize } from "../../../../src" +import { DB_URL, TestDatabase } from "../../../utils" +import { createProductCategories } from "../../../__fixtures__/product-category" + +describe("ProductModuleService product categories", () => { + let service: IProductModuleService + let testManager: SqlEntityManager + let repositoryManager: SqlEntityManager + let productOne: Product + let productTwo: Product + let productCategoryOne: ProductCategory + let productCategoryTwo: ProductCategory + let productCategories: ProductCategory[] + + beforeEach(async () => { + await TestDatabase.setupDatabase() + repositoryManager = await TestDatabase.forkManager() + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA, + }, + }) + + testManager = await TestDatabase.forkManager() + + productOne = testManager.create(Product, { + id: "product-1", + title: "product 1", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + productTwo = testManager.create(Product, { + id: "product-2", + title: "product 2", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + const productCategoriesData = [{ + id: "test-1", + name: "category 1", + products: [productOne], + },{ + id: "test-2", + name: "category", + products: [productTwo], + }] + + productCategories = await createProductCategories( + testManager, + productCategoriesData + ) + + productCategoryOne = productCategories[0] + productCategoryTwo = productCategories[1] + + await testManager.persistAndFlush([productCategoryOne, productCategoryTwo]) + }) + + afterEach(async () => { + await TestDatabase.clearDatabase() + }) + + describe("listCategories", () => { + it("should return categories queried by ID", async () => { + const results = await service.listCategories({ + id: productCategoryOne.id, + }) + + expect(results).toEqual([ + expect.objectContaining({ + id: productCategoryOne.id, + }), + ]) + }) + + it("should return categories based on the options and filter parameter", async () => { + let results = await service.listCategories( + { + id: productCategoryOne.id, + }, + { + take: 1, + } + ) + + expect(results).toEqual([ + expect.objectContaining({ + id: productCategoryOne.id, + }), + ]) + + results = await service.listCategories({}, { take: 1, skip: 1 }) + + expect(results).toEqual([ + expect.objectContaining({ + id: productCategoryTwo.id, + }), + ]) + }) + + it("should return only requested fields and relations for categories", async () => { + const results = await service.listCategories( + { + id: productCategoryOne.id, + }, + { + select: ["id", "name", "products.title"], + relations: ["products"], + } + ) + + expect(results).toEqual([ + expect.objectContaining({ + id: "test-1", + name: "category 1", + products: [expect.objectContaining({ + id: "product-1", + title: "product 1", + })], + }), + ]) + }) + }) + + describe("listAndCountCategories", () => { + it("should return categories and count queried by ID", async () => { + const results = await service.listAndCountCategories({ + id: productCategoryOne.id, + }) + + expect(results[1]).toEqual(1) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: productCategoryOne.id, + }), + ]) + }) + + it("should return categories and count based on the options and filter parameter", async () => { + let results = await service.listAndCountCategories( + { + id: productCategoryOne.id, + }, + { + take: 1, + } + ) + + expect(results[1]).toEqual(1) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: productCategoryOne.id, + }), + ]) + + results = await service.listAndCountCategories({}, { take: 1 }) + + expect(results[1]).toEqual(2) + + results = await service.listAndCountCategories({}, { take: 1, skip: 1 }) + + expect(results[1]).toEqual(2) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: productCategoryTwo.id, + }), + ]) + }) + + it("should return only requested fields and relations for categories", async () => { + const results = await service.listAndCountCategories( + { + id: productCategoryOne.id, + }, + { + select: ["id", "name", "products.title"], + relations: ["products"], + } + ) + + expect(results[1]).toEqual(1) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: "test-1", + name: "category 1", + products: [expect.objectContaining({ + id: "product-1", + title: "product 1", + })], + }), + ]) + }) + }) + + describe("retrieveCategory", () => { + it("should return the requested category", async () => { + const result = await service.retrieveCategory(productCategoryOne.id) + + expect(result).toEqual( + expect.objectContaining({ + id: "test-1", + name: "category 1", + }), + ) + }) + + it("should return requested attributes when requested through config", async () => { + const result = await service.retrieveCategory( + productCategoryOne.id, + { + select: ["id", "name", "products.title"], + relations: ["products"], + } + ) + + expect(result).toEqual( + expect.objectContaining({ + id: "test-1", + name: "category 1", + products: [expect.objectContaining({ + id: "product-1", + title: "product 1", + })], + }), + ) + }) + + it("should throw an error when a category with ID does not exist", async () => { + let error + + try { + await service.retrieveCategory("does-not-exist") + } catch (e) { + error = e + } + + expect(error.message).toEqual("ProductCategory with id: does-not-exist was not found") + }) + }) +}) + diff --git a/packages/product/integration-tests/__tests__/services/product-module-service/product-collections.spec.ts b/packages/product/integration-tests/__tests__/services/product-module-service/product-collections.spec.ts new file mode 100644 index 0000000000..9bba10d633 --- /dev/null +++ b/packages/product/integration-tests/__tests__/services/product-module-service/product-collections.spec.ts @@ -0,0 +1,250 @@ +import { IProductModuleService } from "@medusajs/types" +import { Product, ProductCollection } from "@models" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { ProductTypes } from "@medusajs/types" + +import { initialize } from "../../../../src" +import { DB_URL, TestDatabase } from "../../../utils" +import { createCollections } from "../../../__fixtures__/product" + +describe("ProductModuleService product collections", () => { + let service: IProductModuleService + let testManager: SqlEntityManager + let repositoryManager: SqlEntityManager + let productOne: Product + let productTwo: Product + let productCollectionOne: ProductCollection + let productCollectionTwo: ProductCollection + let productCollections: ProductCollection[] + + beforeEach(async () => { + await TestDatabase.setupDatabase() + repositoryManager = await TestDatabase.forkManager() + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA, + }, + }) + + testManager = await TestDatabase.forkManager() + + productOne = testManager.create(Product, { + id: "product-1", + title: "product 1", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + productTwo = testManager.create(Product, { + id: "product-2", + title: "product 2", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + const productCollectionsData = [{ + id: "test-1", + title: "collection 1", + products: [productOne], + },{ + id: "test-2", + title: "collection", + products: [productTwo], + }] + + productCollections = await createCollections( + testManager, + productCollectionsData + ) + + productCollectionOne = productCollections[0] + productCollectionTwo = productCollections[1] + + await testManager.persistAndFlush([productCollectionOne, productCollectionTwo]) + }) + + afterEach(async () => { + await TestDatabase.clearDatabase() + }) + + describe("listCollections", () => { + it("should return collections queried by ID", async () => { + const results = await service.listCollections({ + id: productCollectionOne.id, + }) + + expect(results).toEqual([ + expect.objectContaining({ + id: productCollectionOne.id, + }), + ]) + }) + + it("should return collections based on the options and filter parameter", async () => { + let results = await service.listCollections( + { + id: productCollectionOne.id, + }, + { + take: 1, + } + ) + + expect(results).toEqual([ + expect.objectContaining({ + id: productCollectionOne.id, + }), + ]) + + results = await service.listCollections({}, { take: 1, skip: 1 }) + + expect(results).toEqual([ + expect.objectContaining({ + id: productCollectionTwo.id, + }), + ]) + }) + + it("should return only requested fields and relations for collections", async () => { + const results = await service.listCollections( + { + id: productCollectionOne.id, + }, + { + select: ["id", "title", "products.title"], + relations: ["products"], + } + ) + + expect(results).toEqual([ + expect.objectContaining({ + id: "test-1", + title: "collection 1", + products: [ + expect.objectContaining({ + id: "product-1", + title: "product 1", + }) + ], + }), + ]) + }) + }) + + describe("listAndCountCollections", () => { + it("should return collections and count queried by ID", async () => { + const results = await service.listAndCountCollections({ + id: productCollectionOne.id, + }) + + expect(results[1]).toEqual(1) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: productCollectionOne.id, + }), + ]) + }) + + it("should return collections and count based on the options and filter parameter", async () => { + let results = await service.listAndCountCollections( + { + id: productCollectionOne.id, + }, + { + take: 1, + } + ) + + expect(results[1]).toEqual(1) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: productCollectionOne.id, + }), + ]) + + results = await service.listAndCountCollections({}, { take: 1 }) + + expect(results[1]).toEqual(2) + + results = await service.listAndCountCollections({}, { take: 1, skip: 1 }) + + expect(results[1]).toEqual(2) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: productCollectionTwo.id, + }), + ]) + }) + + it("should return only requested fields and relations for collections", async () => { + const results = await service.listAndCountCollections( + { + id: productCollectionOne.id, + }, + { + select: ["id", "title", "products.title"], + relations: ["products"], + } + ) + + expect(results[1]).toEqual(1) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: "test-1", + title: "collection 1", + products: [expect.objectContaining({ + id: "product-1", + title: "product 1", + })], + }), + ]) + }) + }) + + describe("retrieveCollection", () => { + it("should return the requested collection", async () => { + const result = await service.retrieveCollection(productCollectionOne.id) + + expect(result).toEqual( + expect.objectContaining({ + id: "test-1", + title: "collection 1", + }), + ) + }) + + it("should return requested attributes when requested through config", async () => { + const result = await service.retrieveCollection( + productCollectionOne.id, + { + select: ["id", "title", "products.title"], + relations: ["products"], + } + ) + + expect(result).toEqual( + expect.objectContaining({ + id: "test-1", + title: "collection 1", + products: [expect.objectContaining({ + id: "product-1", + title: "product 1", + })], + }), + ) + }) + + it("should throw an error when a collection with ID does not exist", async () => { + let error + + try { + await service.retrieveCollection("does-not-exist") + } catch (e) { + error = e + } + + expect(error.message).toEqual("ProductCollection with id: does-not-exist was not found") + }) + }) +}) + diff --git a/packages/product/integration-tests/__tests__/services/product-module-service/product-variants.spec.ts b/packages/product/integration-tests/__tests__/services/product-module-service/product-variants.spec.ts new file mode 100644 index 0000000000..26c5dc43bb --- /dev/null +++ b/packages/product/integration-tests/__tests__/services/product-module-service/product-variants.spec.ts @@ -0,0 +1,182 @@ +import { initialize } from "../../../../src" +import { DB_URL, TestDatabase } from "../../../utils" +import { IProductModuleService } from "@medusajs/types" +import { Product, ProductVariant } from "@models" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { ProductTypes } from "@medusajs/types" + +describe("ProductModuleService product variants", () => { + let service: IProductModuleService + let testManager: SqlEntityManager + let repositoryManager: SqlEntityManager + let variantOne: ProductVariant + let variantTwo: ProductVariant + let productOne: Product + let productTwo: Product + + beforeEach(async () => { + await TestDatabase.setupDatabase() + repositoryManager = await TestDatabase.forkManager() + + service = await initialize({ + database: { + clientUrl: DB_URL, + schema: process.env.MEDUSA_PRODUCT_DB_SCHEMA, + }, + }) + + testManager = await TestDatabase.forkManager() + + productOne = testManager.create(Product, { + id: "product-1", + title: "product 1", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + productTwo = testManager.create(Product, { + id: "product-2", + title: "product 2", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + variantOne = testManager.create(ProductVariant, { + id: "test-1", + title: "variant 1", + inventory_quantity: 10, + product: productOne, + }) + + variantTwo = testManager.create(ProductVariant, { + id: "test-2", + title: "variant", + inventory_quantity: 10, + product: productTwo, + }) + + await testManager.persistAndFlush([variantOne, variantTwo]) + }) + + afterEach(async () => { + await TestDatabase.clearDatabase() + }) + + describe("listAndCountVariants", () => { + it("should return variants and count queried by ID", async () => { + const results = await service.listAndCountVariants({ + id: variantOne.id, + }) + + expect(results[1]).toEqual(1) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: variantOne.id, + }), + ]) + }) + + it("should return variants and count based on the options and filter parameter", async () => { + let results = await service.listAndCountVariants( + { + id: variantOne.id, + }, + { + take: 1, + } + ) + + expect(results[1]).toEqual(1) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: variantOne.id, + }), + ]) + + results = await service.listAndCountVariants({}, { take: 1 }) + + expect(results[1]).toEqual(2) + + results = await service.listAndCountVariants({}, { take: 1, skip: 1 }) + + expect(results[1]).toEqual(2) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: variantTwo.id, + }), + ]) + }) + + it("should return only requested fields and relations for variants", async () => { + const results = await service.listAndCountVariants( + { + id: variantOne.id, + }, + { + select: ["id", "title", "product.title"] as any, + relations: ["product"], + } + ) + + expect(results[1]).toEqual(1) + expect(results[0]).toEqual([ + expect.objectContaining({ + id: "test-1", + title: "variant 1", + product_id: "product-1", + // TODO: investigate why this is returning more than the expected results + product: expect.objectContaining({ + id: "product-1", + title: "product 1", + }), + }), + ]) + }) + }) + + describe("retrieveVariant", () => { + it("should return the requested variant", async () => { + const result = await service.retrieveVariant(variantOne.id) + + expect(result).toEqual( + expect.objectContaining({ + id: "test-1", + title: "variant 1", + }), + ) + }) + + it("should return requested attributes when requested through config", async () => { + const result = await service.retrieveVariant( + variantOne.id, + { + select: ["id", "title", "product.title"] as any, + relations: ["product"], + } + ) + + expect(result).toEqual( + expect.objectContaining({ + id: "test-1", + title: "variant 1", + product_id: "product-1", + product: expect.objectContaining({ + id: "product-1", + title: "product 1", + }), + }), + ) + }) + + it("should throw an error when a variant with ID does not exist", async () => { + let error + + try { + await service.retrieveVariant("does-not-exist") + } catch (e) { + error = e + } + + expect(error.message).toEqual("ProductVariant with id: does-not-exist was not found") + }) + }) +}) + diff --git a/packages/product/integration-tests/__tests__/services/product-tag/index.ts b/packages/product/integration-tests/__tests__/services/product-tag/index.ts index 8b5a7d9c9a..d402c6ecd6 100644 --- a/packages/product/integration-tests/__tests__/services/product-tag/index.ts +++ b/packages/product/integration-tests/__tests__/services/product-tag/index.ts @@ -10,7 +10,7 @@ import { ProductTypes } from "@medusajs/types" jest.setTimeout(30000) -describe("Product Service", () => { +describe("Product tag Service", () => { let service: ProductTagService let testManager: SqlEntityManager let repositoryManager: SqlEntityManager diff --git a/packages/product/integration-tests/__tests__/services/product-variant/index.ts b/packages/product/integration-tests/__tests__/services/product-variant/index.ts index 9d60165a94..af6bbbf52e 100644 --- a/packages/product/integration-tests/__tests__/services/product-variant/index.ts +++ b/packages/product/integration-tests/__tests__/services/product-variant/index.ts @@ -1,6 +1,6 @@ import { TestDatabase } from "../../../utils" -import { ProductVariantService } from "@services" -import { ProductVariantRepository } from "@repositories" +import { ProductService, ProductVariantService } from "@services" +import { ProductRepository, ProductVariantRepository } from "@repositories" import { Product, ProductTag, ProductVariant } from "@models" import { SqlEntityManager } from "@mikro-orm/postgresql" import { Collection } from "@mikro-orm/core" @@ -12,6 +12,7 @@ import { createProductVariants, } from "../../../__fixtures__/product" import { productsData, variantsData } from "../../../__fixtures__/product/data" +import { buildProductVariantOnlyData } from "../../../__fixtures__/variant/data/create-variant" describe("ProductVariant Service", () => { let service: ProductVariantService @@ -20,6 +21,7 @@ describe("ProductVariant Service", () => { let variantOne: ProductVariant let variantTwo: ProductVariant let productOne: Product + const productVariantTestOne = "test-1" beforeEach(async () => { await TestDatabase.setupDatabase() @@ -28,8 +30,17 @@ describe("ProductVariant Service", () => { const productVariantRepository = new ProductVariantRepository({ manager: repositoryManager, }) + const productRepository = new ProductRepository({ + manager: repositoryManager, + }) - service = new ProductVariantService({ productVariantRepository }) + const productService = new ProductService({ + productRepository, + }) + service = new ProductVariantService({ + productService, + productVariantRepository, + }) }) afterEach(async () => { @@ -47,7 +58,7 @@ describe("ProductVariant Service", () => { }) variantOne = testManager.create(ProductVariant, { - id: "test-1", + id: productVariantTestOne, title: "variant 1", inventory_quantity: 10, product: productOne, @@ -95,7 +106,7 @@ describe("ProductVariant Service", () => { it("passing populate, scopes the results of the response", async () => { const results = await service.list( { - id: "test-1", + id: productVariantTestOne, }, { select: ["id", "title", "product.title"] as any, @@ -105,7 +116,7 @@ describe("ProductVariant Service", () => { expect(results).toEqual([ expect.objectContaining({ - id: "test-1", + id: productVariantTestOne, title: "variant 1", product: expect.objectContaining({ id: "product-1", @@ -118,7 +129,7 @@ describe("ProductVariant Service", () => { expect(JSON.parse(JSON.stringify(results))).toEqual([ { - id: "test-1", + id: productVariantTestOne, title: "variant 1", product_id: "product-1", product: { @@ -175,11 +186,148 @@ describe("ProductVariant Service", () => { expect(JSON.parse(JSON.stringify(variants))).toEqual([ expect.objectContaining({ - id: "test-1", + id: productVariantTestOne, title: "variant title", sku: "sku 1", }), ]) }) }) + + describe("create", function () { + let products: Product[] + let productOptions!: ProductOption[] + + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + products = (await createProductAndTags( + testManager, + productsData + )) as Product[] + + productOptions = await createOptions(testManager, [ + { + id: "test-option-1", + title: "size", + product: products[0], + }, + ]) + }) + + it("should create a variant", async () => { + const data = buildProductVariantOnlyData({ + options: [ + { + option: productOptions[0], + value: "XS", + }, + ], + }) + + const variants = await service.create(products[0].id, [data]) + + expect(variants).toHaveLength(1) + expect(variants[0].options).toHaveLength(1) + + expect(JSON.parse(JSON.stringify(variants[0]))).toEqual( + expect.objectContaining({ + id: expect.any(String), + title: data.title, + sku: data.sku, + inventory_quantity: 100, + allow_backorder: false, + manage_inventory: true, + variant_rank: 0, + product: expect.objectContaining({ + id: products[0].id, + }), + options: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + value: data.options![0].value, + }), + ]), + }) + ) + }) + }) + + describe("retrieve", () => { + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + productOne = testManager.create(Product, { + id: "product-1", + title: "product 1", + status: ProductTypes.ProductStatus.PUBLISHED, + }) + + variantOne = testManager.create(ProductVariant, { + id: productVariantTestOne, + title: "variant 1", + inventory_quantity: 10, + product: productOne, + }) + + await testManager.persistAndFlush([variantOne]) + }) + + it("should return the requested variant", async () => { + const result = await service.retrieve(variantOne.id) + + expect(result).toEqual( + expect.objectContaining({ + id: productVariantTestOne, + title: "variant 1", + }), + ) + }) + + it("should return requested attributes when requested through config", async () => { + const result = await service.retrieve( + variantOne.id, + { + select: ["id", "title", "product.title"] as any, + relations: ["product"], + } + ) + + expect(result).toEqual( + expect.objectContaining({ + id: productVariantTestOne, + title: "variant 1", + product_id: "product-1", + product: expect.objectContaining({ + id: "product-1", + title: "product 1", + }), + }), + ) + }) + + it("should throw an error when a variant with ID does not exist", async () => { + let error + + try { + await service.retrieve("does-not-exist") + } catch (e) { + error = e + } + + expect(error.message).toEqual("ProductVariant with id: does-not-exist was not found") + }) + + it("should throw an error when an id is not provided", async () => { + let error + + try { + await service.retrieve(undefined as unknown as string) + } catch (e) { + error = e + } + + expect(error.message).toEqual('"productVariantId" must be defined') + }) + }) }) diff --git a/packages/product/integration-tests/__tests__/services/product/index.ts b/packages/product/integration-tests/__tests__/services/product/index.ts index 08fc942b40..5e9ea1a6af 100644 --- a/packages/product/integration-tests/__tests__/services/product/index.ts +++ b/packages/product/integration-tests/__tests__/services/product/index.ts @@ -1,17 +1,14 @@ import { TestDatabase } from "../../../utils" -import { - ProductService, - ProductTagService, - ProductVariantService, -} from "@services" +import { ProductService } from "@services" import { ProductRepository } from "@repositories" -import { Product, ProductCategory, ProductVariant } from "@models" +import { Image, Product, ProductCategory, ProductVariant } from "@models" import { SqlEntityManager } from "@mikro-orm/postgresql" import { ProductDTO } from "@medusajs/types" import { createProductCategories } from "../../../__fixtures__/product-category" import { assignCategoriesToProduct, + createImages, createProductAndTags, createProductVariants, } from "../../../__fixtures__/product" @@ -20,13 +17,8 @@ import { productsData, variantsData, } from "../../../__fixtures__/product/data" - -const productVariantService = { - list: jest.fn(), -} as unknown as ProductVariantService -const productTagService = { - list: jest.fn(), -} as unknown as ProductTagService +import { buildProductOnlyData } from "../../../__fixtures__/product/data/create-product" +import { kebabCase } from "@medusajs/utils" jest.setTimeout(30000) @@ -48,8 +40,6 @@ describe("Product Service", () => { service = new ProductService({ productRepository, - productVariantService, - productTagService, }) }) @@ -57,7 +47,74 @@ describe("Product Service", () => { await TestDatabase.clearDatabase() }) + describe("create", function () { + let images: Image[] = [] + + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + images = await createImages(testManager, ["image-1"]) + }) + + it("should create a product", async () => { + const data = buildProductOnlyData({ + images, + thumbnail: images[0].url, + }) + + const products = await service.create([data]) + + expect(products).toHaveLength(1) + expect(JSON.parse(JSON.stringify(products[0]))).toEqual( + expect.objectContaining({ + id: expect.any(String), + title: data.title, + handle: kebabCase(data.title), + description: data.description, + subtitle: data.subtitle, + is_giftcard: data.is_giftcard, + discountable: data.discountable, + thumbnail: images[0].url, + status: data.status, + images: expect.arrayContaining([ + expect.objectContaining({ + id: images[0].id, + url: images[0].url, + }), + ]), + }) + ) + }) + }) + describe("list", () => { + describe("soft deleted", function () { + let deletedProduct + let product + + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + const products = await createProductAndTags(testManager, productsData) + + product = products[1] + deletedProduct = await service.softDelete([products[0].id]) + }) + + it("should list all products that are not deleted", async () => { + const products = await service.list() + + expect(products).toHaveLength(1) + expect(products[0].id).toEqual(product.id) + }) + + it("should list all products including the deleted", async () => { + const products = await service.list({}, { withDeleted: true }) + + expect(products).toHaveLength(2) + }) + }) + describe("relation: tags", () => { beforeEach(async () => { testManager = await TestDatabase.forkManager() @@ -65,7 +122,7 @@ describe("Product Service", () => { products = await createProductAndTags(testManager, productsData) }) - it("filter by id and including relations", async () => { + it("should filter by id and including relations", async () => { const productsResult = await service.list( { id: products[0].id, @@ -95,7 +152,7 @@ describe("Product Service", () => { }) }) - it("filter by id and without relations", async () => { + it("should filter by id and without relations", async () => { const productsResult = await service.list({ id: products[0].id, }) @@ -137,7 +194,7 @@ describe("Product Service", () => { ) }) - it("filter by categories relation and scope fields", async () => { + it("should filter by categories relation and scope fields", async () => { const products = await service.list( { id: workingProduct.id, @@ -187,7 +244,7 @@ describe("Product Service", () => { ]) }) - it("returns empty array when querying for a category that doesnt exist", async () => { + it("should returns empty array when querying for a category that doesnt exist", async () => { const products = await service.list( { id: workingProduct.id, @@ -215,7 +272,7 @@ describe("Product Service", () => { variants = await createProductVariants(testManager, variantsData) }) - it("filter by id and including relations", async () => { + it("should filter by id and including relations", async () => { const productsResult = await service.list( { id: products[0].id, @@ -254,4 +311,52 @@ describe("Product Service", () => { }) }) }) + + describe("softDelete", function () { + let images: Image[] = [] + + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + images = await createImages(testManager, ["image-1"]) + }) + + it("should soft delete a product", async () => { + const data = buildProductOnlyData({ + images, + thumbnail: images[0].url, + }) + + const products = await service.create([data]) + const deleteProducts = await service.softDelete(products.map((p) => p.id)) + + expect(deleteProducts).toHaveLength(1) + expect(deleteProducts[0].deleted_at).not.toBeNull() + }) + }) + + describe("restore", function () { + let images: Image[] = [] + + beforeEach(async () => { + testManager = await TestDatabase.forkManager() + + images = await createImages(testManager, ["image-1"]) + }) + + it("should restore a soft deleted product", async () => { + const data = buildProductOnlyData({ + images, + thumbnail: images[0].url, + }) + + const products = await service.create([data]) + const product = products[0] + await service.softDelete([product.id]) + const restoreProducts = await service.restore([product.id]) + + expect(restoreProducts).toHaveLength(1) + expect(restoreProducts[0].deleted_at).toBeNull() + }) + }) }) diff --git a/packages/product/integration-tests/setup.js b/packages/product/integration-tests/setup.js index 1d31c09d98..4c07c20a9c 100644 --- a/packages/product/integration-tests/setup.js +++ b/packages/product/integration-tests/setup.js @@ -1,8 +1,8 @@ const { dropDatabase } = require("pg-god") -const DB_HOST = process.env.DB_HOST -const DB_USERNAME = process.env.DB_USERNAME -const DB_PASSWORD = process.env.DB_PASSWORD +const DB_HOST = process.env.DB_HOST ?? "localhost" +const DB_USERNAME = process.env.DB_USERNAME ?? "postgres" +const DB_PASSWORD = process.env.DB_PASSWORD ?? "" const DB_NAME = process.env.DB_TEMP_NAME const pgGodCredentials = { @@ -16,7 +16,7 @@ afterAll(async () => { await dropDatabase({ databaseName: DB_NAME }, pgGodCredentials) } catch (e) { console.error( - `This might fail if it is run during the unit tests since there is no database to drop. Otherwise, please check what is the issue. ${e}` + `This might fail if it is run during the unit tests since there is no database to drop. Otherwise, please check what is the issue. ${e.message}` ) } }) diff --git a/packages/product/package.json b/packages/product/package.json index b9c8958a94..072b5a35a2 100644 --- a/packages/product/package.json +++ b/packages/product/package.json @@ -39,6 +39,7 @@ "@mikro-orm/cli": "5.7.12", "@mikro-orm/migrations": "5.7.12", "cross-env": "^5.2.1", + "faker": "^6.6.6", "jest": "^25.5.4", "medusa-test-utils": "^1.1.40", "pg-god": "^1.0.12", diff --git a/packages/product/src/initialize/index.ts b/packages/product/src/initialize/index.ts index 1ccfff1622..628230f533 100644 --- a/packages/product/src/initialize/index.ts +++ b/packages/product/src/initialize/index.ts @@ -5,18 +5,14 @@ import { MODULE_PACKAGE_NAMES, Modules, } from "@medusajs/modules-sdk" -import { IProductModuleService } from "@medusajs/types" +import { IProductModuleService, ModulesSdkTypes } from "@medusajs/types" import { moduleDefinition } from "../module-definition" -import { - InitializeModuleInjectableDependencies, - ProductServiceInitializeCustomDataLayerOptions, - ProductServiceInitializeOptions, -} from "../types" +import { InitializeModuleInjectableDependencies } from "../types" export const initialize = async ( options?: - | ProductServiceInitializeOptions - | ProductServiceInitializeCustomDataLayerOptions + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions | ExternalModuleDeclaration, injectedDependencies?: InitializeModuleInjectableDependencies ): Promise => { diff --git a/packages/product/src/loaders/connection.ts b/packages/product/src/loaders/connection.ts index 53d448b7d0..85863dc91e 100644 --- a/packages/product/src/loaders/connection.ts +++ b/packages/product/src/loaders/connection.ts @@ -6,24 +6,21 @@ import { MODULE_RESOURCE_TYPE, MODULE_SCOPE, } from "@medusajs/modules-sdk" -import { MedusaError } from "@medusajs/utils" +import { MedusaError, ModulesSdkUtils } from "@medusajs/utils" import { EntitySchema } from "@mikro-orm/core" import * as ProductModels from "@models" -import { - ProductServiceInitializeCustomDataLayerOptions, - ProductServiceInitializeOptions, -} from "../types" -import { createConnection, loadDatabaseConfig } from "../utils" +import { createConnection } from "../utils" +import { ModulesSdkTypes } from "@medusajs/types" export default async ( { options, container, }: LoaderOptions< - | ProductServiceInitializeOptions - | ProductServiceInitializeCustomDataLayerOptions + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions >, moduleDeclaration?: InternalModuleDeclaration ): Promise => { @@ -35,11 +32,11 @@ export default async ( } const customManager = ( - options as ProductServiceInitializeCustomDataLayerOptions + options as ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions )?.manager if (!customManager) { - const dbData = loadDatabaseConfig(options) + const dbData = ModulesSdkUtils.loadDatabaseConfig("product", options) await loadDefault({ database: dbData, container }) } else { container.register({ diff --git a/packages/product/src/loaders/container.ts b/packages/product/src/loaders/container.ts index 120b6ce2b0..3975dcbbb4 100644 --- a/packages/product/src/loaders/container.ts +++ b/packages/product/src/loaders/container.ts @@ -3,36 +3,39 @@ import { LoaderOptions } from "@medusajs/modules-sdk" import { asClass } from "awilix" import { ProductCategoryService, + ProductCollectionService, + ProductImageService, ProductModuleService, + ProductOptionService, ProductService, ProductTagService, + ProductTypeService, ProductVariantService, - ProductCollectionService, } from "@services" import * as DefaultRepositories from "@repositories" import { + BaseRepository, ProductCategoryRepository, ProductCollectionRepository, + ProductImageRepository, + ProductOptionRepository, ProductRepository, ProductTagRepository, + ProductTypeRepository, ProductVariantRepository, } from "@repositories" -import { - ProductServiceInitializeCustomDataLayerOptions, - ProductServiceInitializeOptions, -} from "../types" -import { Constructor, DAL } from "@medusajs/types" +import { Constructor, DAL, ModulesSdkTypes } from "@medusajs/types" import { lowerCaseFirst } from "@medusajs/utils" export default async ({ container, options, }: LoaderOptions< - | ProductServiceInitializeOptions - | ProductServiceInitializeCustomDataLayerOptions + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions >): Promise => { const customRepositories = ( - options as ProductServiceInitializeCustomDataLayerOptions + options as ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions )?.repositories container.register({ @@ -42,6 +45,9 @@ export default async ({ productVariantService: asClass(ProductVariantService).singleton(), productTagService: asClass(ProductTagService).singleton(), productCollectionService: asClass(ProductCollectionService).singleton(), + productImageService: asClass(ProductImageService).singleton(), + productTypeService: asClass(ProductTypeService).singleton(), + productOptionService: asClass(ProductOptionService).singleton(), }) if (customRepositories) { @@ -53,13 +59,17 @@ export default async ({ function loadDefaultRepositories({ container }) { container.register({ - productRepository: asClass(ProductRepository).singleton(), - productVariantRepository: asClass(ProductVariantRepository).singleton(), - productTagRepository: asClass(ProductTagRepository).singleton(), + baseRepository: asClass(BaseRepository).singleton(), + productImageRepository: asClass(ProductImageRepository).singleton(), productCategoryRepository: asClass(ProductCategoryRepository).singleton(), productCollectionRepository: asClass( ProductCollectionRepository ).singleton(), + productRepository: asClass(ProductRepository).singleton(), + productTagRepository: asClass(ProductTagRepository).singleton(), + productTypeRepository: asClass(ProductTypeRepository).singleton(), + productOptionRepository: asClass(ProductOptionRepository).singleton(), + productVariantRepository: asClass(ProductVariantRepository).singleton(), }) } diff --git a/packages/product/src/migrations/.snapshot-medusa-products.json b/packages/product/src/migrations/.snapshot-medusa-products.json index 0150443c95..43fb0c6d2d 100644 --- a/packages/product/src/migrations/.snapshot-medusa-products.json +++ b/packages/product/src/migrations/.snapshot-medusa-products.json @@ -212,6 +212,15 @@ "name": "product_collection", "schema": "public", "indexes": [ + { + "columnNames": [ + "deleted_at" + ], + "composite": false, + "keyName": "IDX_product_collection_deleted_at", + "primary": false, + "unique": false + }, { "keyName": "IDX_product_collection_handle_unique", "columnNames": [ @@ -234,6 +243,80 @@ "checks": [], "foreignKeys": {} }, + { + "columns": { + "id": { + "name": "id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "url": { + "name": "url", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "json" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "image", + "schema": "public", + "indexes": [ + { + "columnNames": [ + "url" + ], + "composite": false, + "keyName": "IDX_product_image_url", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "deleted_at" + ], + "composite": false, + "keyName": "IDX_product_image_deleted_at", + "primary": false, + "unique": false + }, + { + "keyName": "image_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {} + }, { "columns": { "id": { @@ -277,6 +360,15 @@ "name": "product_tag", "schema": "public", "indexes": [ + { + "columnNames": [ + "deleted_at" + ], + "composite": false, + "keyName": "IDX_product_tag_deleted_at", + "primary": false, + "unique": false + }, { "keyName": "product_tag_pkey", "columnNames": [ @@ -333,6 +425,15 @@ "name": "product_type", "schema": "public", "indexes": [ + { + "columnNames": [ + "deleted_at" + ], + "composite": false, + "keyName": "IDX_product_type_deleted_at", + "primary": false, + "unique": false + }, { "keyName": "product_type_pkey", "columnNames": [ @@ -588,6 +689,15 @@ "primary": false, "unique": false }, + { + "columnNames": [ + "deleted_at" + ], + "composite": false, + "keyName": "IDX_product_deleted_at", + "primary": false, + "unique": false + }, { "keyName": "IDX_product_handle_unique", "columnNames": [ @@ -698,6 +808,15 @@ "primary": false, "unique": false }, + { + "columnNames": [ + "deleted_at" + ], + "composite": false, + "keyName": "IDX_product_option_deleted_at", + "primary": false, + "unique": false + }, { "keyName": "product_option_pkey", "columnNames": [ @@ -789,6 +908,71 @@ } } }, + { + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + }, + "product_image_id": { + "name": "product_image_id", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "text" + } + }, + "name": "product_images", + "schema": "public", + "indexes": [ + { + "keyName": "product_images_pkey", + "columnNames": [ + "product_id", + "product_image_id" + ], + "composite": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": { + "product_images_product_id_foreign": { + "constraintName": "product_images_product_id_foreign", + "columnNames": [ + "product_id" + ], + "localTableName": "public.product_images", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.product", + "deleteRule": "cascade", + "updateRule": "cascade" + }, + "product_images_product_image_id_foreign": { + "constraintName": "product_images_product_image_id_foreign", + "columnNames": [ + "product_image_id" + ], + "localTableName": "public.product_images", + "referencedColumnNames": [ + "id" + ], + "referencedTableName": "public.image", + "deleteRule": "cascade", + "updateRule": "cascade" + } + } + }, { "columns": { "product_id": { @@ -917,6 +1101,7 @@ "autoincrement": false, "primary": false, "nullable": false, + "default": "100", "mappedType": "decimal" }, "allow_backorder": { @@ -1027,6 +1212,7 @@ "autoincrement": false, "primary": false, "nullable": true, + "default": "0", "mappedType": "decimal" }, "created_at": { @@ -1072,12 +1258,21 @@ "name": "product_variant", "schema": "public", "indexes": [ + { + "columnNames": [ + "deleted_at" + ], + "composite": false, + "keyName": "IDX_product_variant_deleted_at", + "primary": false, + "unique": false + }, { "columnNames": [ "product_id" ], "composite": false, - "keyName": "IDX_product_variant_product_id_index", + "keyName": "IDX_product_variant_product_id", "primary": false, "unique": false }, @@ -1210,7 +1405,25 @@ "option_id" ], "composite": false, - "keyName": "IDX_product_option_value_product_option", + "keyName": "IDX_product_option_value_option_id", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "variant_id" + ], + "composite": false, + "keyName": "IDX_product_option_value_variant_id", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "deleted_at" + ], + "composite": false, + "keyName": "IDX_product_option_value_deleted_at", "primary": false, "unique": false }, diff --git a/packages/product/src/migrations/Migration20230609132805.ts b/packages/product/src/migrations/Migration20230609132805.ts deleted file mode 100644 index 43baf27f65..0000000000 --- a/packages/product/src/migrations/Migration20230609132805.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Migration } from '@mikro-orm/migrations'; - -export class Migration20230609132805 extends Migration { - - async up(): Promise { - this.addSql('create table "product_category" ("id" text not null, "name" text not null, "description" text not null default \'\', "handle" text not null, "mpath" text not null, "is_active" boolean not null default false, "is_internal" boolean not null default false, "rank" numeric not null default 0, "parent_category_id" text null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "product_category_pkey" primary key ("id"));'); - this.addSql('create index "IDX_product_category_path" on "product_category" ("mpath");'); - this.addSql('alter table "product_category" add constraint "IDX_product_category_handle" unique ("handle");'); - - this.addSql('create table "product_collection" ("id" text not null, "title" text not null, "handle" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_collection_pkey" primary key ("id"));'); - this.addSql('alter table "product_collection" add constraint "IDX_product_collection_handle_unique" unique ("handle");'); - - this.addSql('create table "product_tag" ("id" text not null, "value" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_tag_pkey" primary key ("id"));'); - - this.addSql('create table "product_type" ("id" text not null, "value" text not null, "metadata" json null, "deleted_at" timestamptz null, constraint "product_type_pkey" primary key ("id"));'); - - this.addSql('create table "product" ("id" text not null, "title" text not null, "handle" text not null, "subtitle" text null, "description" text null, "is_giftcard" boolean not null default false, "status" text check ("status" in (\'draft\', \'proposed\', \'published\', \'rejected\')) not null, "thumbnail" text null, "weight" text null, "length" text null, "height" text null, "width" text null, "origin_country" text null, "hs_code" text null, "mid_code" text null, "material" text null, "collection_id" text null, "type_id" text null, "discountable" boolean not null default true, "external_id" text null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, "metadata" jsonb null, constraint "product_pkey" primary key ("id"));'); - this.addSql('create index "IDX_product_type_id" on "product" ("type_id");'); - this.addSql('alter table "product" add constraint "IDX_product_handle_unique" unique ("handle");'); - - this.addSql('create table "product_option" ("id" text not null, "title" text not null, "product_id" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_option_pkey" primary key ("id"));'); - this.addSql('create index "IDX_product_option_product_id" on "product_option" ("product_id");'); - - this.addSql('create table "product_tags" ("product_id" text not null, "product_tag_id" text not null, constraint "product_tags_pkey" primary key ("product_id", "product_tag_id"));'); - - this.addSql('create table "product_category_product" ("product_id" text not null, "product_category_id" text not null, constraint "product_category_product_pkey" primary key ("product_id", "product_category_id"));'); - - this.addSql('create table "product_variant" ("id" text not null, "title" text not null, "sku" text null, "barcode" text null, "ean" text null, "upc" text null, "inventory_quantity" numeric not null, "allow_backorder" boolean not null default false, "manage_inventory" boolean not null default true, "hs_code" text null, "origin_country" text null, "mid_code" text null, "material" text null, "weight" numeric null, "length" numeric null, "height" numeric null, "width" numeric null, "metadata" jsonb null, "variant_rank" numeric null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, "product_id" text not null, constraint "product_variant_pkey" primary key ("id"));'); - this.addSql('create index "IDX_product_variant_product_id_index" on "product_variant" ("product_id");'); - this.addSql('alter table "product_variant" add constraint "IDX_product_variant_sku_unique" unique ("sku");'); - this.addSql('alter table "product_variant" add constraint "IDX_product_variant_barcode_unique" unique ("barcode");'); - this.addSql('alter table "product_variant" add constraint "IDX_product_variant_ean_unique" unique ("ean");'); - this.addSql('alter table "product_variant" add constraint "IDX_product_variant_upc_unique" unique ("upc");'); - - this.addSql('create table "product_option_value" ("id" text not null, "value" text not null, "option_id" text not null, "variant_id" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_option_value_pkey" primary key ("id"));'); - this.addSql('create index "IDX_product_option_value_product_option" on "product_option_value" ("option_id");'); - - this.addSql('alter table "product_category" add constraint "product_category_parent_category_id_foreign" foreign key ("parent_category_id") references "product_category" ("id") on update cascade on delete set null;'); - - this.addSql('alter table "product" add constraint "product_collection_id_foreign" foreign key ("collection_id") references "product_collection" ("id") on update cascade on delete set null;'); - this.addSql('alter table "product" add constraint "product_type_id_foreign" foreign key ("type_id") references "product_type" ("id") on update cascade on delete set null;'); - - this.addSql('alter table "product_option" add constraint "product_option_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade;'); - - this.addSql('alter table "product_tags" add constraint "product_tags_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'); - this.addSql('alter table "product_tags" add constraint "product_tags_product_tag_id_foreign" foreign key ("product_tag_id") references "product_tag" ("id") on update cascade on delete cascade;'); - - this.addSql('alter table "product_category_product" add constraint "product_category_product_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'); - this.addSql('alter table "product_category_product" add constraint "product_category_product_product_category_id_foreign" foreign key ("product_category_id") references "product_category" ("id") on update cascade on delete cascade;'); - - this.addSql('alter table "product_variant" add constraint "product_variant_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;'); - - this.addSql('alter table "product_option_value" add constraint "product_option_value_option_id_foreign" foreign key ("option_id") references "product_option" ("id") on update cascade;'); - this.addSql('alter table "product_option_value" add constraint "product_option_value_variant_id_foreign" foreign key ("variant_id") references "product_variant" ("id") on update cascade on delete cascade;'); - } - -} diff --git a/packages/product/src/migrations/Migration20230710091208.ts b/packages/product/src/migrations/Migration20230710091208.ts new file mode 100644 index 0000000000..2efaa5f9d6 --- /dev/null +++ b/packages/product/src/migrations/Migration20230710091208.ts @@ -0,0 +1,162 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20230710091208 extends Migration { + async up(): Promise { + this.addSql( + 'create table "product_category" ("id" text not null, "name" text not null, "description" text not null default \'\', "handle" text not null, "mpath" text not null, "is_active" boolean not null default false, "is_internal" boolean not null default false, "rank" numeric not null default 0, "parent_category_id" text null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "product_category_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_product_category_path" on "product_category" ("mpath");' + ) + this.addSql( + 'alter table "product_category" add constraint "IDX_product_category_handle" unique ("handle");' + ) + + this.addSql( + 'create table "product_collection" ("id" text not null, "title" text not null, "handle" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_collection_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_product_collection_deleted_at" on "product_collection" ("deleted_at");' + ) + this.addSql( + 'alter table "product_collection" add constraint "IDX_product_collection_handle_unique" unique ("handle");' + ) + + this.addSql( + 'create table "image" ("id" text not null, "url" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "image_pkey" primary key ("id"));' + ) + this.addSql('create index "IDX_product_image_url" on "image" ("url");') + this.addSql( + 'create index "IDX_product_image_deleted_at" on "image" ("deleted_at");' + ) + + this.addSql( + 'create table "product_tag" ("id" text not null, "value" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_tag_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_product_tag_deleted_at" on "product_tag" ("deleted_at");' + ) + + this.addSql( + 'create table "product_type" ("id" text not null, "value" text not null, "metadata" json null, "deleted_at" timestamptz null, constraint "product_type_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_product_type_deleted_at" on "product_type" ("deleted_at");' + ) + + this.addSql( + 'create table "product" ("id" text not null, "title" text not null, "handle" text not null, "subtitle" text null, "description" text null, "is_giftcard" boolean not null default false, "status" text check ("status" in (\'draft\', \'proposed\', \'published\', \'rejected\')) not null, "thumbnail" text null, "weight" text null, "length" text null, "height" text null, "width" text null, "origin_country" text null, "hs_code" text null, "mid_code" text null, "material" text null, "collection_id" text null, "type_id" text null, "discountable" boolean not null default true, "external_id" text null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, "metadata" jsonb null, constraint "product_pkey" primary key ("id"));' + ) + this.addSql('create index "IDX_product_type_id" on "product" ("type_id");') + this.addSql( + 'create index "IDX_product_deleted_at" on "product" ("deleted_at");' + ) + this.addSql( + 'alter table "product" add constraint "IDX_product_handle_unique" unique ("handle");' + ) + + this.addSql( + 'create table "product_option" ("id" text not null, "title" text not null, "product_id" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_option_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_product_option_product_id" on "product_option" ("product_id");' + ) + this.addSql( + 'create index "IDX_product_option_deleted_at" on "product_option" ("deleted_at");' + ) + + this.addSql( + 'create table "product_tags" ("product_id" text not null, "product_tag_id" text not null, constraint "product_tags_pkey" primary key ("product_id", "product_tag_id"));' + ) + + this.addSql( + 'create table "product_images" ("product_id" text not null, "product_image_id" text not null, constraint "product_images_pkey" primary key ("product_id", "product_image_id"));' + ) + + this.addSql( + 'create table "product_category_product" ("product_id" text not null, "product_category_id" text not null, constraint "product_category_product_pkey" primary key ("product_id", "product_category_id"));' + ) + + this.addSql( + 'create table "product_variant" ("id" text not null, "title" text not null, "sku" text null, "barcode" text null, "ean" text null, "upc" text null, "inventory_quantity" numeric not null default 100, "allow_backorder" boolean not null default false, "manage_inventory" boolean not null default true, "hs_code" text null, "origin_country" text null, "mid_code" text null, "material" text null, "weight" numeric null, "length" numeric null, "height" numeric null, "width" numeric null, "metadata" jsonb null, "variant_rank" numeric null default 0, "created_at" timestamptz not null, "updated_at" timestamptz not null, "deleted_at" timestamptz null, "product_id" text not null, constraint "product_variant_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_product_variant_deleted_at" on "product_variant" ("deleted_at");' + ) + this.addSql( + 'create index "IDX_product_variant_product_id" on "product_variant" ("product_id");' + ) + this.addSql( + 'alter table "product_variant" add constraint "IDX_product_variant_sku_unique" unique ("sku");' + ) + this.addSql( + 'alter table "product_variant" add constraint "IDX_product_variant_barcode_unique" unique ("barcode");' + ) + this.addSql( + 'alter table "product_variant" add constraint "IDX_product_variant_ean_unique" unique ("ean");' + ) + this.addSql( + 'alter table "product_variant" add constraint "IDX_product_variant_upc_unique" unique ("upc");' + ) + + this.addSql( + 'create table "product_option_value" ("id" text not null, "value" text not null, "option_id" text not null, "variant_id" text not null, "metadata" jsonb null, "deleted_at" timestamptz null, constraint "product_option_value_pkey" primary key ("id"));' + ) + this.addSql( + 'create index "IDX_product_option_value_option_id" on "product_option_value" ("option_id");' + ) + this.addSql( + 'create index "IDX_product_option_value_variant_id" on "product_option_value" ("variant_id");' + ) + this.addSql( + 'create index "IDX_product_option_value_deleted_at" on "product_option_value" ("deleted_at");' + ) + + this.addSql( + 'alter table "product_category" add constraint "product_category_parent_category_id_foreign" foreign key ("parent_category_id") references "product_category" ("id") on update cascade on delete set null;' + ) + + this.addSql( + 'alter table "product" add constraint "product_collection_id_foreign" foreign key ("collection_id") references "product_collection" ("id") on update cascade on delete set null;' + ) + this.addSql( + 'alter table "product" add constraint "product_type_id_foreign" foreign key ("type_id") references "product_type" ("id") on update cascade on delete set null;' + ) + + this.addSql( + 'alter table "product_option" add constraint "product_option_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade;' + ) + + this.addSql( + 'alter table "product_tags" add constraint "product_tags_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;' + ) + this.addSql( + 'alter table "product_tags" add constraint "product_tags_product_tag_id_foreign" foreign key ("product_tag_id") references "product_tag" ("id") on update cascade on delete cascade;' + ) + + this.addSql( + 'alter table "product_images" add constraint "product_images_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;' + ) + this.addSql( + 'alter table "product_images" add constraint "product_images_product_image_id_foreign" foreign key ("product_image_id") references "image" ("id") on update cascade on delete cascade;' + ) + + this.addSql( + 'alter table "product_category_product" add constraint "product_category_product_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;' + ) + this.addSql( + 'alter table "product_category_product" add constraint "product_category_product_product_category_id_foreign" foreign key ("product_category_id") references "product_category" ("id") on update cascade on delete cascade;' + ) + + this.addSql( + 'alter table "product_variant" add constraint "product_variant_product_id_foreign" foreign key ("product_id") references "product" ("id") on update cascade on delete cascade;' + ) + + this.addSql( + 'alter table "product_option_value" add constraint "product_option_value_option_id_foreign" foreign key ("option_id") references "product_option" ("id") on update cascade;' + ) + this.addSql( + 'alter table "product_option_value" add constraint "product_option_value_variant_id_foreign" foreign key ("variant_id") references "product_variant" ("id") on update cascade on delete cascade;' + ) + } +} diff --git a/packages/product/src/models/index.ts b/packages/product/src/models/index.ts index 6575294d57..6017c491d7 100644 --- a/packages/product/src/models/index.ts +++ b/packages/product/src/models/index.ts @@ -4,3 +4,5 @@ export { default as ProductCollection } from "./product-collection" export { default as ProductTag } from "./product-tag" export { default as ProductType } from "./product-type" export { default as ProductVariant } from "./product-variant" +export { default as ProductOption } from "./product-option" +export { default as Image } from "./product-image" diff --git a/packages/product/src/models/product-category.ts b/packages/product/src/models/product-category.ts index 91ccc0358a..eedc7a155a 100644 --- a/packages/product/src/models/product-category.ts +++ b/packages/product/src/models/product-category.ts @@ -21,7 +21,7 @@ class ProductCategory { id!: string @Property({ columnType: "text", nullable: false }) - name: string + name?: string @Property({ columnType: "text", default: "", nullable: false }) description?: string diff --git a/packages/product/src/models/product-collection.ts b/packages/product/src/models/product-collection.ts index 90a7830e86..6f364daa2c 100644 --- a/packages/product/src/models/product-collection.ts +++ b/packages/product/src/models/product-collection.ts @@ -1,15 +1,26 @@ import { BeforeCreate, + Collection, Entity, + Index, + OneToMany, + OptionalProps, PrimaryKey, Property, Unique, } from "@mikro-orm/core" import { generateEntityId, kebabCase } from "@medusajs/utils" +import Product from "./product" +import { SoftDeletable } from "../utils" + +type OptionalRelations = "products" @Entity({ tableName: "product_collection" }) +@SoftDeletable() class ProductCollection { + [OptionalProps]?: OptionalRelations + @PrimaryKey({ columnType: "text" }) id!: string @@ -21,13 +32,17 @@ class ProductCollection { name: "IDX_product_collection_handle_unique", properties: ["handle"], }) - handle: string + handle?: string + + @OneToMany(() => Product, (product) => product.collection) + products = new Collection(this) @Property({ columnType: "jsonb", nullable: true }) metadata?: Record | null + @Index({ name: "IDX_product_collection_deleted_at" }) @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date + deleted_at?: Date @BeforeCreate() onCreate() { diff --git a/packages/product/src/models/product-image.ts b/packages/product/src/models/product-image.ts new file mode 100644 index 0000000000..a142a186e7 --- /dev/null +++ b/packages/product/src/models/product-image.ts @@ -0,0 +1,46 @@ +import { + BeforeCreate, + Collection, + Entity, + Index, + ManyToMany, + OptionalProps, + PrimaryKey, + Property, +} from "@mikro-orm/core" + +import { generateEntityId } from "@medusajs/utils" +import Product from "./product" +import { SoftDeletable } from "../utils" + +type OptionalRelations = "products" + +@Entity({ tableName: "image" }) +@SoftDeletable() +class ProductImage { + [OptionalProps]?: OptionalRelations + + @PrimaryKey({ columnType: "text" }) + id!: string + + @Index({ name: "IDX_product_image_url" }) + @Property({ columnType: "text" }) + url: string + + @Property({ columnType: "jsonb", nullable: true }) + metadata?: Record | null + + @Index({ name: "IDX_product_image_deleted_at" }) + @Property({ columnType: "timestamptz", nullable: true }) + deleted_at?: Date + + @ManyToMany(() => Product, (product) => product.images) + products = new Collection(this) + + @BeforeCreate() + onCreate() { + this.id = generateEntityId(this.id, "img") + } +} + +export default ProductImage diff --git a/packages/product/src/models/product-option-value.ts b/packages/product/src/models/product-option-value.ts index 814955a91e..764c605549 100644 --- a/packages/product/src/models/product-option-value.ts +++ b/packages/product/src/models/product-option-value.ts @@ -1,7 +1,9 @@ import { BeforeCreate, Entity, + Index, ManyToOne, + OptionalProps, PrimaryKey, Property, } from "@mikro-orm/core" @@ -9,28 +11,53 @@ import { generateEntityId } from "@medusajs/utils" import ProductOption from "./product-option" import { ProductVariant } from "./index" +import { SoftDeletable } from "../utils" + +type OptionalFields = + | "created_at" + | "updated_at" + | "allow_backorder" + | "manage_inventory" + | "option_id" + | "variant_id" +type OptionalRelations = "product" | "option" | "variant" @Entity({ tableName: "product_option_value" }) +@SoftDeletable() class ProductOptionValue { + [OptionalProps]?: OptionalFields | OptionalRelations + @PrimaryKey({ columnType: "text" }) id!: string @Property({ columnType: "text" }) value: string + @Property({ persist: false }) + option_id!: string + @ManyToOne(() => ProductOption, { - index: "IDX_product_option_value_product_option", + index: "IDX_product_option_value_option_id", + fieldName: "option_id", }) option: ProductOption - @ManyToOne(() => ProductVariant, { onDelete: "cascade" }) + @Property({ persist: false }) + variant_id!: string + + @ManyToOne(() => ProductVariant, { + onDelete: "cascade", + index: "IDX_product_option_value_variant_id", + fieldName: "variant_id", + }) variant: ProductVariant @Property({ columnType: "jsonb", nullable: true }) metadata?: Record | null + @Index({ name: "IDX_product_option_value_deleted_at" }) @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date + deleted_at?: Date @BeforeCreate() beforeCreate() { diff --git a/packages/product/src/models/product-option.ts b/packages/product/src/models/product-option.ts index 28f1419c32..3ce3246c66 100644 --- a/packages/product/src/models/product-option.ts +++ b/packages/product/src/models/product-option.ts @@ -3,17 +3,26 @@ import { Cascade, Collection, Entity, + Index, ManyToOne, OneToMany, + OptionalProps, PrimaryKey, Property, } from "@mikro-orm/core" import { generateEntityId } from "@medusajs/utils" import { Product } from "./index" import ProductOptionValue from "./product-option-value" +import { SoftDeletable } from "../utils" + +type OptionalRelations = "values" | "product" +type OptionalFields = "product_id" @Entity({ tableName: "product_option" }) +@SoftDeletable() class ProductOption { + [OptionalProps]?: OptionalRelations | OptionalFields + @PrimaryKey({ columnType: "text" }) id!: string @@ -21,23 +30,25 @@ class ProductOption { title: string @Property({ persist: false }) - product_id!: number + product_id!: string @ManyToOne(() => Product, { index: "IDX_product_option_product_id", + fieldName: "product_id", }) product: Product @OneToMany(() => ProductOptionValue, (value) => value.option, { - cascade: [Cascade.REMOVE], + cascade: [Cascade.REMOVE, "soft-remove" as any], }) values = new Collection(this) @Property({ columnType: "jsonb", nullable: true }) metadata?: Record | null + @Index({ name: "IDX_product_option_deleted_at" }) @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date + deleted_at?: Date @BeforeCreate() beforeCreate() { diff --git a/packages/product/src/models/product-tag.ts b/packages/product/src/models/product-tag.ts index 5fc16a00d7..aceee12e4d 100644 --- a/packages/product/src/models/product-tag.ts +++ b/packages/product/src/models/product-tag.ts @@ -2,16 +2,24 @@ import { BeforeCreate, Collection, Entity, + Index, ManyToMany, + OptionalProps, PrimaryKey, Property, } from "@mikro-orm/core" import { generateEntityId } from "@medusajs/utils" import Product from "./product" +import { SoftDeletable } from "../utils" + +type OptionalRelations = "products" @Entity({ tableName: "product_tag" }) +@SoftDeletable() class ProductTag { + [OptionalProps]?: OptionalRelations + @PrimaryKey({ columnType: "text" }) id!: string @@ -21,8 +29,9 @@ class ProductTag { @Property({ columnType: "jsonb", nullable: true }) metadata?: Record | null + @Index({ name: "IDX_product_tag_deleted_at" }) @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date + deleted_at?: Date @ManyToMany(() => Product, (product) => product.tags) products = new Collection(this) diff --git a/packages/product/src/models/product-type.ts b/packages/product/src/models/product-type.ts index df4b3618df..e2c4616e3a 100644 --- a/packages/product/src/models/product-type.ts +++ b/packages/product/src/models/product-type.ts @@ -1,8 +1,16 @@ -import { BeforeCreate, Entity, PrimaryKey, Property } from "@mikro-orm/core" +import { + BeforeCreate, + Entity, + Index, + PrimaryKey, + Property, +} from "@mikro-orm/core" import { generateEntityId } from "@medusajs/utils" +import { SoftDeletable } from "../utils" @Entity({ tableName: "product_type" }) +@SoftDeletable() class ProductType { @PrimaryKey({ columnType: "text" }) id!: string @@ -13,8 +21,9 @@ class ProductType { @Property({ columnType: "json", nullable: true }) metadata?: Record | null + @Index({ name: "IDX_product_type_deleted_at" }) @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date + deleted_at?: Date @BeforeCreate() onCreate() { diff --git a/packages/product/src/models/product-variant.ts b/packages/product/src/models/product-variant.ts index 85fdea578e..b8c7ccf329 100644 --- a/packages/product/src/models/product-variant.ts +++ b/packages/product/src/models/product-variant.ts @@ -3,6 +3,7 @@ import { Cascade, Collection, Entity, + Index, ManyToOne, OneToMany, OptionalProps, @@ -13,18 +14,18 @@ import { import { generateEntityId } from "@medusajs/utils" import { Product } from "@models" import ProductOptionValue from "./product-option-value" +import { SoftDeletable } from "../utils" type OptionalFields = | "created_at" | "updated_at" - | "updated_at" - | "deleted_at" | "allow_backorder" | "manage_inventory" | "product" | "product_id" @Entity({ tableName: "product_variant" }) +@SoftDeletable() class ProductVariant { [OptionalProps]?: OptionalFields @@ -65,14 +66,14 @@ class ProductVariant { // Note: Upon serialization, this turns to a string. This is on purpose, because you would loose // precision if you cast numeric to JS number, as JS number is a float. // Ref: https://github.com/mikro-orm/mikro-orm/issues/2295 - @Property({ columnType: "numeric" }) - inventory_quantity: number + @Property({ columnType: "numeric", default: 100 }) + inventory_quantity?: number = 100 @Property({ columnType: "boolean", default: false }) - allow_backorder: boolean + allow_backorder?: boolean = false @Property({ columnType: "boolean", default: true }) - manage_inventory: boolean + manage_inventory?: boolean = true @Property({ columnType: "text", nullable: true }) hs_code?: string | null @@ -101,7 +102,7 @@ class ProductVariant { @Property({ columnType: "jsonb", nullable: true }) metadata?: Record | null - @Property({ columnType: "numeric", nullable: true }) + @Property({ columnType: "numeric", nullable: true, default: 0 }) variant_rank?: number | null @Property({ persist: false }) @@ -117,18 +118,19 @@ class ProductVariant { }) updated_at: Date + @Index({ name: "IDX_product_variant_deleted_at" }) @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date + deleted_at?: Date @ManyToOne(() => Product, { onDelete: "cascade", - index: "IDX_product_variant_product_id_index", + index: "IDX_product_variant_product_id", fieldName: "product_id", }) product!: Product @OneToMany(() => ProductOptionValue, (optionValue) => optionValue.variant, { - cascade: [Cascade.PERSIST, Cascade.REMOVE], + cascade: [Cascade.PERSIST, Cascade.REMOVE, "soft-remove" as any], }) options = new Collection(this) diff --git a/packages/product/src/models/product.ts b/packages/product/src/models/product.ts index c3659a1382..1a228b9ea5 100644 --- a/packages/product/src/models/product.ts +++ b/packages/product/src/models/product.ts @@ -3,6 +3,7 @@ import { Collection, Entity, Enum, + Index, ManyToMany, ManyToOne, OneToMany, @@ -20,16 +21,20 @@ import ProductOption from "./product-option" import ProductTag from "./product-tag" import ProductType from "./product-type" import ProductVariant from "./product-variant" +import ProductImage from "./product-image" +import { SoftDeletable } from "../utils" type OptionalRelations = "collection" | "type" type OptionalFields = + | "collection_id" + | "type_id" | "is_giftcard" | "discountable" | "created_at" | "updated_at" - | "deleted_at" @Entity({ tableName: "product" }) +@SoftDeletable() class Product { [OptionalProps]?: OptionalRelations | OptionalFields @@ -58,16 +63,17 @@ class Product { @Enum(() => ProductTypes.ProductStatus) status!: ProductTypes.ProductStatus - // TODO: add images model - // images: Image[] - @Property({ columnType: "text", nullable: true }) thumbnail?: string | null - @OneToMany(() => ProductOption, (o) => o.product) + @OneToMany(() => ProductOption, (o) => o.product, { + cascade: ["soft-remove"] as any, + }) options = new Collection(this) - @OneToMany(() => ProductVariant, (variant) => variant.product) + @OneToMany(() => ProductVariant, (variant) => variant.product, { + cascade: ["soft-remove"] as any, + }) variants = new Collection(this) @Property({ columnType: "text", nullable: true }) @@ -94,12 +100,22 @@ class Product { @Property({ columnType: "text", nullable: true }) material?: string | null - @ManyToOne(() => ProductCollection, { nullable: true }) + @Property({ persist: false }) + collection_id!: string + + @ManyToOne(() => ProductCollection, { + nullable: true, + fieldName: "collection_id", + }) collection!: ProductCollection + @Property({ persist: false }) + type_id!: string + @ManyToOne(() => ProductType, { nullable: true, index: "IDX_product_type_id", + fieldName: "type_id", }) type!: ProductType @@ -107,12 +123,22 @@ class Product { owner: true, pivotTable: "product_tags", index: "IDX_product_tag_id", + cascade: ["soft-remove"] as any, }) tags = new Collection(this) + @ManyToMany(() => ProductImage, "products", { + owner: true, + pivotTable: "product_images", + index: "IDX_product_image_id", + cascade: ["soft-remove"] as any, + }) + images = new Collection(this) + @ManyToMany(() => ProductCategory, "products", { owner: true, pivotTable: "product_category_product", + cascade: ["soft-remove"] as any, }) categories = new Collection(this) @@ -132,8 +158,9 @@ class Product { }) updated_at: Date + @Index({ name: "IDX_product_deleted_at" }) @Property({ columnType: "timestamptz", nullable: true }) - deleted_at: Date + deleted_at?: Date @Property({ columnType: "jsonb", nullable: true }) metadata?: Record | null diff --git a/packages/product/src/module-definition.ts b/packages/product/src/module-definition.ts index 956865776f..be772a047f 100644 --- a/packages/product/src/module-definition.ts +++ b/packages/product/src/module-definition.ts @@ -3,7 +3,6 @@ import { ProductModuleService } from "@services" import loadContainer from "./loaders/container" import loadConnection from "./loaders/connection" import * as ProductModels from "@models" -import { revertMigration, runMigrations } from "./scripts" const service = ProductModuleService const loaders = [loadContainer, loadConnection] as any @@ -13,6 +12,4 @@ export const moduleDefinition: ModuleExports = { service, loaders, models, - runMigrations, - revertMigration, } diff --git a/packages/product/src/repositories/base.ts b/packages/product/src/repositories/base.ts new file mode 100644 index 0000000000..0321f86610 --- /dev/null +++ b/packages/product/src/repositories/base.ts @@ -0,0 +1,243 @@ +import { Context, DAL, RepositoryTransformOptions } from "@medusajs/types" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { + buildQuery, + InjectTransactionManager, + MedusaContext, +} from "@medusajs/utils" +import { serialize } from "@mikro-orm/core" +import { doNotForceTransaction } from "../utils" + +// TODO: Should we create a mikro orm specific package for this and the soft deletable decorator util? + +async function transactionWrapper( + this: any, + task: (transactionManager: unknown) => Promise, + { + transaction, + isolationLevel, + enableNestedTransactions = false, + }: { + isolationLevel?: string + transaction?: unknown + enableNestedTransactions?: boolean + } = {} +): Promise { + // Reuse the same transaction if it is already provided and nested transactions are disabled + if (!enableNestedTransactions && transaction) { + return await task(transaction) + } + + const forkedManager = this.manager_.fork() + + const options = {} + if (isolationLevel) { + Object.assign(options, { isolationLevel }) + } + + if (transaction) { + Object.assign(options, { ctx: transaction }) + await forkedManager.begin(options) + } else { + await forkedManager.begin(options) + } + + try { + const result = await task(forkedManager) + await forkedManager.commit() + return result + } catch (e) { + await forkedManager.rollback() + throw e + } +} + +const updateDeletedAtRecursively = async ( + manager: SqlEntityManager, + entities: T[], + value: Date | null +) => { + for await (const entity of entities) { + if (!("deleted_at" in entity)) continue + ;(entity as any).deleted_at = value + + const relations = manager + .getDriver() + .getMetadata() + .get(entities[0].constructor.name).relations + + const relationsToCascade = relations.filter((relation) => + relation.cascade.includes("soft-remove" as any) + ) + + for (const relation of relationsToCascade) { + const relationEntities = (await entity[relation.name].init()).getItems({ + filters: { + [DAL.SoftDeletableFilterKey]: { + withDeleted: true, + }, + }, + }) + + await updateDeletedAtRecursively(manager, relationEntities, value) + } + + await manager.persist(entities) + } +} + +const serializer = < + T extends object | object[], + TResult extends object | object[] +>( + data: T, + options?: any +): Promise => { + options ??= {} + const result = serialize(data, options) + return Array.isArray(data) ? result : result[0] +} + +export abstract class AbstractBaseRepository + implements DAL.RepositoryService +{ + protected readonly manager_: SqlEntityManager + + protected constructor({ manager }) { + this.manager_ = manager + } + + async transaction( + task: (transactionManager: unknown) => Promise, + { + transaction, + isolationLevel, + enableNestedTransactions = false, + }: { + isolationLevel?: string + enableNestedTransactions?: boolean + transaction?: unknown + } = {} + ): Promise { + return await transactionWrapper.apply(this, arguments) + } + + serialize< + TData extends object | object[] = object[], + TResult extends object | object[] = object[] + >(data: TData, options?: any): Promise { + return serializer(data, options) + } + + abstract find(options?: DAL.FindOptions, context?: Context) + + abstract findAndCount( + options?: DAL.FindOptions, + context?: Context + ): Promise<[T[], number]> + + abstract create(data: unknown[], context?: Context): Promise + + abstract delete(ids: string[], context?: Context): Promise + + @InjectTransactionManager() + async softDelete( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + const entities = await this.find({ where: { id: { $in: ids } } as any }) + + const date = new Date() + await updateDeletedAtRecursively( + manager as SqlEntityManager, + entities, + date + ) + + return entities + } + + @InjectTransactionManager() + async restore( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + const query = buildQuery( + { id: { $in: ids } }, + { + withDeleted: true, + } + ) + + const entities = await this.find(query) + + await updateDeletedAtRecursively( + manager as SqlEntityManager, + entities, + null + ) + + return entities + } +} + +export abstract class AbstractTreeRepositoryBase + extends AbstractBaseRepository + implements DAL.TreeRepositoryService +{ + protected constructor({ manager }) { + // @ts-ignore + super(...arguments) + } + + abstract find( + options?: DAL.FindOptions, + transformOptions?: RepositoryTransformOptions, + context?: Context + ) + + abstract findAndCount( + options?: DAL.FindOptions, + transformOptions?: RepositoryTransformOptions, + context?: Context + ): Promise<[T[], number]> +} + +/** + * Only used internally in order to be able to wrap in transaction from a + * non identified repository + */ +export class BaseRepository extends AbstractBaseRepository { + constructor({ manager }) { + // @ts-ignore + super(...arguments) + } + + serialize< + TData extends object | object[] = object[], + TResult extends object | object[] = object[] + >(data: TData, options?: any): Promise { + return serializer(data, options) + } + + create(data: unknown[], context?: Context): Promise { + throw new Error("Method not implemented.") + } + + delete(ids: string[], context?: Context): Promise { + throw new Error("Method not implemented.") + } + + find(options?: DAL.FindOptions, context?: Context): Promise { + throw new Error("Method not implemented.") + } + + findAndCount( + options?: DAL.FindOptions, + context?: Context + ): Promise<[any[], number]> { + throw new Error("Method not implemented.") + } +} diff --git a/packages/product/src/repositories/index.ts b/packages/product/src/repositories/index.ts index cd99d6ec97..bc8199a386 100644 --- a/packages/product/src/repositories/index.ts +++ b/packages/product/src/repositories/index.ts @@ -1,5 +1,9 @@ +export { BaseRepository } from "./base" export { ProductRepository } from "./product" export { ProductTagRepository } from "./product-tag" export { ProductVariantRepository } from "./product-variant" export { ProductCollectionRepository } from "./product-collection" export { ProductCategoryRepository } from "./product-category" +export { ProductImageRepository } from "./product-image" +export { ProductTypeRepository } from "./product-type" +export { ProductOptionRepository } from "./product-option" diff --git a/packages/product/src/repositories/product-category.ts b/packages/product/src/repositories/product-category.ts index 1551f0d48c..b02b00d775 100644 --- a/packages/product/src/repositories/product-category.ts +++ b/packages/product/src/repositories/product-category.ts @@ -1,35 +1,36 @@ -import { SqlEntityManager } from "@mikro-orm/postgresql" import { FilterQuery as MikroFilterQuery, FindOptions as MikroOptions, LoadStrategy, } from "@mikro-orm/core" -import { deduplicateIfNecessary } from "../utils" -import { ProductCategory } from "@models" -import { DAL, ProductCategoryTransformOptions } from "@medusajs/types" +import { Product, ProductCategory } from "@models" +import { Context, DAL, ProductCategoryTransformOptions } from "@medusajs/types" import groupBy from "lodash/groupBy" +import { AbstractTreeRepositoryBase } from "./base" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" -export class ProductCategoryRepository - implements DAL.RepositoryService -{ +export class ProductCategoryRepository extends AbstractTreeRepositoryBase { protected readonly manager_: SqlEntityManager - constructor({ manager }) { - this.manager_ = manager.fork() + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + super(...arguments) + this.manager_ = manager } async find( findOptions: DAL.FindOptions = { where: {} }, transformOptions: ProductCategoryTransformOptions = {}, - context: { transaction?: any } = {} + context: Context = {} ): Promise { - // Spread is used to copy the options in case of manipulation to prevent side effects + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + const findOptions_ = { ...findOptions } const { includeDescendantsTree } = transformOptions - findOptions_.options ??= {} const fields = (findOptions_.options.fields ??= []) - findOptions_.options.limit ??= 15 // Ref: Building descendants // mpath and parent_category_id needs to be added to the query for the tree building to be done accurately @@ -39,19 +40,11 @@ export class ProductCategoryRepository fields.push("parent_category_id") } - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) - } - Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, }) - const productCategories = await this.manager_.find( + const productCategories = await manager.find( ProductCategory, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions @@ -69,8 +62,12 @@ export class ProductCategoryRepository async buildProductCategoriesWithDescendants( productCategories: ProductCategory[], - findOptions: DAL.FindOptions = { where: {} } + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} ): Promise { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + for (let productCategory of productCategories) { const whereOptions = { ...findOptions.where, @@ -78,9 +75,11 @@ export class ProductCategoryRepository $like: `${productCategory.mpath}%`, }, } - delete whereOptions.parent_category_id - const descendantsForCategory = await this.manager_.find( + delete whereOptions.parent_category_id + delete whereOptions.id + + const descendantsForCategory = await manager.find( ProductCategory, whereOptions as MikroFilterQuery, findOptions.options as MikroOptions @@ -111,30 +110,64 @@ export class ProductCategoryRepository async findAndCount( findOptions: DAL.FindOptions = { where: {} }, transformOptions: ProductCategoryTransformOptions = {}, - context: { transaction?: any } = {} + context: Context = {} ): Promise<[ProductCategory[], number]> { - // Spread is used to copy the options in case of manipulation to prevent side effects + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + const findOptions_ = { ...findOptions } - + const { includeDescendantsTree } = transformOptions findOptions_.options ??= {} - findOptions_.options.limit ??= 15 + const fields = (findOptions_.options.fields ??= []) - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) + // Ref: Building descendants + // mpath and parent_category_id needs to be added to the query for the tree building to be done accurately + if (includeDescendantsTree) { + fields.indexOf("mpath") === -1 && fields.push("mpath") + fields.indexOf("parent_category_id") === -1 && + fields.push("parent_category_id") } Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, }) - return await this.manager_.findAndCount( + const [productCategories, count] = await manager.findAndCount( ProductCategory, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions ) + + if (!includeDescendantsTree) { + return [productCategories, count] + } + + return [ + await this.buildProductCategoriesWithDescendants( + productCategories, + findOptions_ + ), + count, + ] + } + + @InjectTransactionManager() + async delete( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + await (manager as SqlEntityManager).nativeDelete( + Product, + { id: { $in: ids } }, + {} + ) + } + + async create( + data: unknown[], + context: Context = {} + ): Promise { + throw new Error("Method not implemented.") } } diff --git a/packages/product/src/repositories/product-collection.ts b/packages/product/src/repositories/product-collection.ts index c32b0c71d0..adde4237a7 100644 --- a/packages/product/src/repositories/product-collection.ts +++ b/packages/product/src/repositories/product-collection.ts @@ -1,74 +1,84 @@ -import { SqlEntityManager } from "@mikro-orm/postgresql" -import { ProductCollection } from "@models" +import { Product, ProductCollection } from "@models" import { FilterQuery as MikroFilterQuery, FindOptions as MikroOptions, LoadStrategy, } from "@mikro-orm/core" -import { deduplicateIfNecessary } from "../utils" -import { DAL } from "@medusajs/types" +import { Context, DAL } from "@medusajs/types" +import { AbstractBaseRepository } from "./base" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" -export class ProductCollectionRepository implements DAL.RepositoryService { +export class ProductCollectionRepository extends AbstractBaseRepository { protected readonly manager_: SqlEntityManager - constructor({ manager }) { - this.manager_ = manager.fork() + + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + super(...arguments) + this.manager_ = manager } - async find( - findOptions: DAL.FindOptions = { where: {} }, - context: { transaction?: any } = {} - ): Promise { - // Spread is used to copy the options in case of manipulation to prevent side effects + async find( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + const findOptions_ = { ...findOptions } - findOptions_.options ??= {} - findOptions_.options.limit ??= 15 - - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) - } Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, }) - return (await this.manager_.find( + return await manager.find( ProductCollection, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions - )) as unknown as T[] + ) } - async findAndCount( - findOptions: DAL.FindOptions = { where: {} }, - context: { transaction?: any } = {} - ): Promise<[T[], number]> { - // Spread is used to copy the options in case of manipulation to prevent side effects + async findAndCount( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise<[ProductCollection[], number]> { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + const findOptions_ = { ...findOptions } - findOptions_.options ??= {} - findOptions_.options.limit ??= 15 - - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) - } Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, }) - return (await this.manager_.findAndCount( + return await manager.findAndCount( ProductCollection, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions - )) as unknown as [T[], number] + ) + } + + @InjectTransactionManager() + async delete( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + await (manager as SqlEntityManager).nativeDelete( + Product, + { id: { $in: ids } }, + {} + ) + } + + @InjectTransactionManager() + async create( + data: unknown[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + throw new Error("Method not implemented.") } } diff --git a/packages/product/src/repositories/product-image.ts b/packages/product/src/repositories/product-image.ts new file mode 100644 index 0000000000..084f712c38 --- /dev/null +++ b/packages/product/src/repositories/product-image.ts @@ -0,0 +1,128 @@ +import { + FilterQuery as MikroFilterQuery, + FindOptions as MikroOptions, + LoadStrategy, +} from "@mikro-orm/core" +import { Context, DAL } from "@medusajs/types" +import { Image, Product } from "@models" +import { AbstractBaseRepository } from "./base" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" + +export class ProductImageRepository extends AbstractBaseRepository { + protected readonly manager_: SqlEntityManager + + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + super(...arguments) + this.manager_ = manager + } + + async find( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + Object.assign(findOptions_.options, { + strategy: LoadStrategy.SELECT_IN, + }) + + return await manager.find( + Image, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + async findAndCount( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise<[Image[], number]> { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + Object.assign(findOptions_.options, { + strategy: LoadStrategy.SELECT_IN, + }) + + return await manager.findAndCount( + Image, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + @InjectTransactionManager() + async upsert( + urls: string[], + @MedusaContext() + context: Context = {} + ): Promise { + const { transactionManager: manager } = context + + const existingImages = await this.find( + { + where: { + url: { + $in: urls, + }, + }, + }, + context + ) + + const existingImagesMap = new Map( + existingImages.map<[string, Image]>((img) => [img.url, img]) + ) + + const upsertedImgs: Image[] = [] + const imageToCreate: Image[] = [] + + urls.forEach((url) => { + const aImg = existingImagesMap.get(url) + if (aImg) { + upsertedImgs.push(aImg) + } else { + const newImg = (manager as SqlEntityManager).create(Image, { url }) + imageToCreate.push(newImg) + } + }) + + if (imageToCreate.length) { + await (manager as SqlEntityManager).persist(imageToCreate) + upsertedImgs.push(...imageToCreate) + } + + return upsertedImgs + } + + @InjectTransactionManager() + async delete( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + await (manager as SqlEntityManager).nativeDelete( + Product, + { id: { $in: ids } }, + {} + ) + } + + @InjectTransactionManager() + async create( + data: unknown[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + throw new Error("Method not implemented.") + } +} diff --git a/packages/product/src/repositories/product-option.ts b/packages/product/src/repositories/product-option.ts new file mode 100644 index 0000000000..b3fa0138f8 --- /dev/null +++ b/packages/product/src/repositories/product-option.ts @@ -0,0 +1,90 @@ +import { + FilterQuery as MikroFilterQuery, + FindOptions as MikroOptions, + LoadStrategy, +} from "@mikro-orm/core" +import { Product, ProductOption } from "@models" +import { Context, DAL, ProductTypes } from "@medusajs/types" +import { AbstractBaseRepository } from "./base" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" + +export class ProductOptionRepository extends AbstractBaseRepository { + protected readonly manager_: SqlEntityManager + + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + super(...arguments) + this.manager_ = manager + } + + async find( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + Object.assign(findOptions_.options, { + strategy: LoadStrategy.SELECT_IN, + }) + + return await manager.find( + ProductOption, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + async findAndCount( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise<[ProductOption[], number]> { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + Object.assign(findOptions_.options, { + strategy: LoadStrategy.SELECT_IN, + }) + + return await manager.findAndCount( + ProductOption, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + @InjectTransactionManager() + async delete( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + await (manager as SqlEntityManager).nativeDelete( + Product, + { id: { $in: ids } }, + {} + ) + } + + @InjectTransactionManager() + async create( + data: (ProductTypes.CreateProductOptionDTO & { product: { id: string } })[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + const options = data.map((option) => { + return (manager as SqlEntityManager).create(ProductOption, option) + }) + + await (manager as SqlEntityManager).persist(options) + + return options + } +} diff --git a/packages/product/src/repositories/product-tag.ts b/packages/product/src/repositories/product-tag.ts index 5fc13f1fde..1dbb318ad1 100644 --- a/packages/product/src/repositories/product-tag.ts +++ b/packages/product/src/repositories/product-tag.ts @@ -1,74 +1,135 @@ -import { SqlEntityManager } from "@mikro-orm/postgresql" import { FilterQuery as MikroFilterQuery, FindOptions as MikroOptions, LoadStrategy, + RequiredEntityData, } from "@mikro-orm/core" -import { deduplicateIfNecessary } from "../utils" -import { ProductTag } from "@models" -import { DAL } from "@medusajs/types" +import { Product, ProductTag } from "@models" +import { Context, CreateProductTagDTO, DAL } from "@medusajs/types" +import { AbstractBaseRepository } from "./base" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" -export class ProductTagRepository implements DAL.RepositoryService { +export class ProductTagRepository extends AbstractBaseRepository { protected readonly manager_: SqlEntityManager - constructor({ manager }) { - this.manager_ = manager.fork() + + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + super(...arguments) + this.manager_ = manager } - async find( - findOptions: DAL.FindOptions = { where: {} }, - context: { transaction?: any } = {} - ): Promise { - // Spread is used to copy the options in case of manipulation to prevent side effects + async find( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + const findOptions_ = { ...findOptions } - findOptions_.options ??= {} - findOptions_.options.limit ??= 15 - - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) - } Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, }) - return (await this.manager_.find( + return await manager.find( ProductTag, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions - )) as unknown as T[] + ) } - async findAndCount( - findOptions: DAL.FindOptions = { where: {} }, - context: { transaction?: any } = {} - ): Promise<[T[], number]> { - // Spread is used to copy the options in case of manipulation to prevent side effects + async findAndCount( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise<[ProductTag[], number]> { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + const findOptions_ = { ...findOptions } - findOptions_.options ??= {} - findOptions_.options.limit ??= 15 - - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) - } Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, }) - return (await this.manager_.findAndCount( + return await manager.findAndCount( ProductTag, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions - )) as unknown as [T[], number] + ) + } + + @InjectTransactionManager() + async upsert( + tags: CreateProductTagDTO[], + @MedusaContext() + context: Context = {} + ): Promise { + const { transactionManager: manager } = context + + const tagsValues = tags.map((tag) => tag.value) + const existingTags = await this.find( + { + where: { + value: { + $in: tagsValues, + }, + }, + }, + context + ) + + const existingTagsMap = new Map( + existingTags.map<[string, ProductTag]>((tag) => [tag.value, tag]) + ) + + const upsertedTags: ProductTag[] = [] + const tagsToCreate: RequiredEntityData[] = [] + + tags.forEach((tag) => { + const aTag = existingTagsMap.get(tag.value) + if (aTag) { + upsertedTags.push(aTag) + } else { + const newTag = (manager as SqlEntityManager).create(ProductTag, tag) + tagsToCreate.push(newTag) + } + }) + + if (tagsToCreate.length) { + const newTags: ProductTag[] = [] + tagsToCreate.forEach((tag) => { + newTags.push((manager as SqlEntityManager).create(ProductTag, tag)) + }) + + await (manager as SqlEntityManager).persist(newTags) + upsertedTags.push(...newTags) + } + + return upsertedTags + } + + @InjectTransactionManager() + async delete( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + await (manager as SqlEntityManager).nativeDelete( + Product, + { id: { $in: ids } }, + {} + ) + } + + @InjectTransactionManager() + async create( + data: unknown[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + throw new Error("Method not implemented.") } } diff --git a/packages/product/src/repositories/product-type.ts b/packages/product/src/repositories/product-type.ts new file mode 100644 index 0000000000..6d69cce031 --- /dev/null +++ b/packages/product/src/repositories/product-type.ts @@ -0,0 +1,135 @@ +import { + FilterQuery as MikroFilterQuery, + FindOptions as MikroOptions, + LoadStrategy, + RequiredEntityData, +} from "@mikro-orm/core" +import { Product, ProductType } from "@models" +import { Context, CreateProductTypeDTO, DAL } from "@medusajs/types" +import { AbstractBaseRepository } from "./base" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" + +export class ProductTypeRepository extends AbstractBaseRepository { + protected readonly manager_: SqlEntityManager + + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + super(...arguments) + this.manager_ = manager + } + + async find( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + Object.assign(findOptions_.options, { + strategy: LoadStrategy.SELECT_IN, + }) + + return await manager.find( + ProductType, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + async findAndCount( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise<[ProductType[], number]> { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + + const findOptions_ = { ...findOptions } + findOptions_.options ??= {} + + Object.assign(findOptions_.options, { + strategy: LoadStrategy.SELECT_IN, + }) + + return await manager.findAndCount( + ProductType, + findOptions_.where as MikroFilterQuery, + findOptions_.options as MikroOptions + ) + } + + @InjectTransactionManager() + async upsert( + types: CreateProductTypeDTO[], + @MedusaContext() + context: Context = {} + ): Promise { + const { transactionManager: manager } = context + + const typesValues = types.map((type) => type.value) + const existingTypes = await this.find( + { + where: { + value: { + $in: typesValues, + }, + }, + }, + context + ) + + const existingTypesMap = new Map( + existingTypes.map<[string, ProductType]>((type) => [type.value, type]) + ) + + const upsertedTypes: ProductType[] = [] + const typesToCreate: RequiredEntityData[] = [] + + types.forEach((type) => { + const aType = existingTypesMap.get(type.value) + if (aType) { + upsertedTypes.push(aType) + } else { + const newType = (manager as SqlEntityManager).create(ProductType, type) + typesToCreate.push(newType) + } + }) + + if (typesToCreate.length) { + const newTypes: ProductType[] = [] + typesToCreate.forEach((type) => { + newTypes.push((manager as SqlEntityManager).create(ProductType, type)) + }) + + await (manager as SqlEntityManager).persist(newTypes) + upsertedTypes.push(...newTypes) + } + + return upsertedTypes + } + + @InjectTransactionManager() + async delete( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + await (manager as SqlEntityManager).nativeDelete( + Product, + { id: { $in: ids } }, + {} + ) + } + + @InjectTransactionManager() + async create( + data: unknown[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + throw new Error("Method not implemented.") + } +} diff --git a/packages/product/src/repositories/product-variant.ts b/packages/product/src/repositories/product-variant.ts index 939bc500bc..2940e1db55 100644 --- a/packages/product/src/repositories/product-variant.ts +++ b/packages/product/src/repositories/product-variant.ts @@ -1,74 +1,92 @@ -import { SqlEntityManager } from "@mikro-orm/postgresql" import { FilterQuery as MikroFilterQuery, FindOptions as MikroOptions, LoadStrategy, + RequiredEntityData, } from "@mikro-orm/core" -import { deduplicateIfNecessary } from "../utils" -import { ProductVariant } from "@models" -import { DAL } from "@medusajs/types" +import { Product, ProductVariant } from "@models" +import { Context, DAL } from "@medusajs/types" +import { AbstractBaseRepository } from "./base" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" +import { doNotForceTransaction } from "../utils" -export class ProductVariantRepository implements DAL.RepositoryService { +export class ProductVariantRepository extends AbstractBaseRepository { protected readonly manager_: SqlEntityManager - constructor({ manager }) { - this.manager_ = manager.fork() + + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + super(...arguments) + this.manager_ = manager } - async find( - findOptions: DAL.FindOptions = { where: {} }, - context: { transaction?: any } = {} - ): Promise { - // Spread is used to copy the options in case of manipulation to prevent side effects + async find( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + const findOptions_ = { ...findOptions } - findOptions_.options ??= {} - findOptions_.options.limit ??= 15 - - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) - } Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, }) - return (await this.manager_.find( + return await manager.find( ProductVariant, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions - )) as unknown as T[] + ) } - async findAndCount( - findOptions: DAL.FindOptions = { where: {} }, - context: { transaction?: any } = {} - ): Promise<[T[], number]> { - // Spread is used to copy the options in case of manipulation to prevent side effects + async findAndCount( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} + ): Promise<[ProductVariant[], number]> { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + const findOptions_ = { ...findOptions } - findOptions_.options ??= {} - findOptions_.options.limit ??= 15 - - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) - } Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, }) - return (await this.manager_.findAndCount( + return await manager.findAndCount( ProductVariant, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions - )) as unknown as [T[], number] + ) + } + + @InjectTransactionManager() + async delete( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + await (manager as SqlEntityManager).nativeDelete( + Product, + { id: { $in: ids } }, + {} + ) + } + + @InjectTransactionManager() + async create( + data: RequiredEntityData[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + const variants = data.map((variant) => { + return (manager as SqlEntityManager).create(ProductVariant, variant) + }) + + await (manager as SqlEntityManager).persist(variants) + + return variants } } diff --git a/packages/product/src/repositories/product.ts b/packages/product/src/repositories/product.ts index bd6d4f5e5f..5efaef2ff3 100644 --- a/packages/product/src/repositories/product.ts +++ b/packages/product/src/repositories/product.ts @@ -1,36 +1,37 @@ -import { SqlEntityManager } from "@mikro-orm/postgresql" import { Product } from "@models" import { FilterQuery as MikroFilterQuery, FindOptions as MikroOptions, LoadStrategy, } from "@mikro-orm/core" -import { deduplicateIfNecessary } from "../utils" -import { DAL } from "@medusajs/types" +import { + Context, + DAL, + ProductTypes, + WithRequiredProperty, +} from "@medusajs/types" +import { AbstractBaseRepository } from "./base" +import { SqlEntityManager } from "@mikro-orm/postgresql" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" -export class ProductRepository implements DAL.RepositoryService { +export class ProductRepository extends AbstractBaseRepository { protected readonly manager_: SqlEntityManager - constructor({ manager }) { - this.manager_ = manager.fork() + + constructor({ manager }: { manager: SqlEntityManager }) { + // @ts-ignore + super(...arguments) + this.manager_ = manager } async find( findOptions: DAL.FindOptions = { where: {} }, - context: { transaction?: any } = {} + context: Context = {} ): Promise { - // Spread is used to cssopy the options in case of manipulation to prevent side effects + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + const findOptions_ = { ...findOptions } - findOptions_.options ??= {} - findOptions_.options.limit ??= 15 - - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) - } Object.assign(findOptions_.options, { strategy: LoadStrategy.SELECT_IN, @@ -38,7 +39,7 @@ export class ProductRepository implements DAL.RepositoryService { await this.mutateNotInCategoriesConstraints(findOptions_) - return await this.manager_.find( + return await manager.find( Product, findOptions_.where as MikroFilterQuery, findOptions_.options as MikroOptions @@ -47,20 +48,13 @@ export class ProductRepository implements DAL.RepositoryService { async findAndCount( findOptions: DAL.FindOptions = { where: {} }, - context: { transaction?: any } = {} + context: Context = {} ): Promise<[Product[], number]> { - // Spread is used to copy the options in case of manipulation to prevent side effects const findOptions_ = { ...findOptions } - findOptions_.options ??= {} - findOptions_.options.limit ??= 15 - if (findOptions_.options.populate) { - deduplicateIfNecessary(findOptions_.options.populate) - } - - if (context.transaction) { - Object.assign(findOptions_.options, { ctx: context.transaction }) + if (context.transactionManager) { + Object.assign(findOptions_.options, { ctx: context.transactionManager }) } Object.assign(findOptions_.options, { @@ -75,17 +69,20 @@ export class ProductRepository implements DAL.RepositoryService { findOptions_.options as MikroOptions ) } - /** * In order to be able to have a strict not in categories, and prevent a product * to be return in the case it also belongs to other categories, we need to * first find all products that are in the categories, and then exclude them */ - private async mutateNotInCategoriesConstraints( - findOptions: DAL.FindOptions = { where: {} } + protected async mutateNotInCategoriesConstraints( + findOptions: DAL.FindOptions = { where: {} }, + context: Context = {} ): Promise { + const manager = (context.transactionManager ?? + this.manager_) as SqlEntityManager + if (findOptions.where.categories?.id?.["$nin"]) { - const productsInCategories = await this.manager_.find( + const productsInCategories = await manager.find( Product, { categories: { @@ -109,4 +106,33 @@ export class ProductRepository implements DAL.RepositoryService { } } } + + @InjectTransactionManager() + async delete( + ids: string[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + await (manager as SqlEntityManager).nativeDelete( + Product, + { id: { $in: ids } }, + {} + ) + } + + @InjectTransactionManager() + async create( + data: WithRequiredProperty[], + @MedusaContext() + { transactionManager: manager }: Context = {} + ): Promise { + console.log((this as any).prototype) + const products = data.map((product) => { + return (manager as SqlEntityManager).create(Product, product) + }) + + await (manager as SqlEntityManager).persist(products) + + return products + } } diff --git a/packages/product/src/scripts/migration-down.ts b/packages/product/src/scripts/migration-down.ts index c81d4858ec..ad0a1b7fce 100644 --- a/packages/product/src/scripts/migration-down.ts +++ b/packages/product/src/scripts/migration-down.ts @@ -1,11 +1,8 @@ -import { LoaderOptions, Logger } from "@medusajs/types" -import { - ProductServiceInitializeCustomDataLayerOptions, - ProductServiceInitializeOptions, -} from "../types" -import { createConnection, loadDatabaseConfig } from "../utils" +import { LoaderOptions, Logger, ModulesSdkTypes } from "@medusajs/types" +import { createConnection } from "../utils" import * as ProductModels from "@models" import { EntitySchema } from "@mikro-orm/core" +import { ModulesSdkUtils } from "@medusajs/utils" /** * This script is only valid for mikro orm managers. If a user provide a custom manager @@ -19,14 +16,14 @@ export async function revertMigration({ logger, }: Pick< LoaderOptions< - | ProductServiceInitializeOptions - | ProductServiceInitializeCustomDataLayerOptions + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions >, "options" | "logger" > = {}) { logger ??= console as unknown as Logger - const dbData = loadDatabaseConfig(options) + const dbData = ModulesSdkUtils.loadDatabaseConfig("product", options) const entities = Object.values(ProductModels) as unknown as EntitySchema[] const orm = await createConnection(dbData, entities) diff --git a/packages/product/src/scripts/migration-up.ts b/packages/product/src/scripts/migration-up.ts index 46b9d6090c..06c72e7ac9 100644 --- a/packages/product/src/scripts/migration-up.ts +++ b/packages/product/src/scripts/migration-up.ts @@ -1,11 +1,8 @@ -import { LoaderOptions, Logger } from "@medusajs/types" -import { - ProductServiceInitializeCustomDataLayerOptions, - ProductServiceInitializeOptions, -} from "../types" -import { createConnection, loadDatabaseConfig } from "../utils" +import { LoaderOptions, Logger, ModulesSdkTypes } from "@medusajs/types" +import { createConnection } from "../utils" import * as ProductModels from "@models" import { EntitySchema } from "@mikro-orm/core" +import { ModulesSdkUtils } from "@medusajs/utils" /** * This script is only valid for mikro orm managers. If a user provide a custom manager @@ -19,14 +16,14 @@ export async function runMigrations({ logger, }: Pick< LoaderOptions< - | ProductServiceInitializeOptions - | ProductServiceInitializeCustomDataLayerOptions + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions >, "options" | "logger" > = {}) { logger ??= console as unknown as Logger - const dbData = loadDatabaseConfig(options) + const dbData = ModulesSdkUtils.loadDatabaseConfig("product", options) const entities = Object.values(ProductModels) as unknown as EntitySchema[] const orm = await createConnection(dbData, entities) diff --git a/packages/product/src/scripts/seed.ts b/packages/product/src/scripts/seed.ts index f5b0a4a04e..ec29d7605c 100644 --- a/packages/product/src/scripts/seed.ts +++ b/packages/product/src/scripts/seed.ts @@ -1,15 +1,12 @@ -import { createConnection, loadDatabaseConfig } from "../utils" +import { createConnection } from "../utils" import * as ProductModels from "@models" import { Product, ProductCategory, ProductVariant } from "@models" import { EntitySchema } from "@mikro-orm/core" -import { LoaderOptions, Logger } from "@medusajs/types" -import { - ProductServiceInitializeCustomDataLayerOptions, - ProductServiceInitializeOptions, -} from "../types" +import { LoaderOptions, Logger, ModulesSdkTypes } from "@medusajs/types" import { EOL } from "os" import { SqlEntityManager } from "@mikro-orm/postgresql" import { resolve } from "path" +import { ModulesSdkUtils } from "@medusajs/utils" export async function run({ options, @@ -18,8 +15,8 @@ export async function run({ }: Partial< Pick< LoaderOptions< - | ProductServiceInitializeOptions - | ProductServiceInitializeCustomDataLayerOptions + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions >, "options" | "logger" > @@ -38,7 +35,7 @@ export async function run({ logger ??= console as unknown as Logger - const dbData = loadDatabaseConfig(options) + const dbData = ModulesSdkUtils.loadDatabaseConfig("product", options) const entities = Object.values(ProductModels) as unknown as EntitySchema[] const orm = await createConnection(dbData, entities) diff --git a/packages/product/src/services/__fixtures__/product.ts b/packages/product/src/services/__fixtures__/product.ts new file mode 100644 index 0000000000..aa4377bef8 --- /dev/null +++ b/packages/product/src/services/__fixtures__/product.ts @@ -0,0 +1,20 @@ +import { asClass, asValue, createContainer } from "awilix" +import { ProductService } from "@services" + +export const nonExistingProductId = "non-existing-id" + +export const mockContainer = createContainer() +mockContainer.register({ + transaction: asValue(async (task) => await task()), + productRepository: asValue({ + find: jest.fn().mockImplementation(async ({ where: { id } }) => { + if (id === nonExistingProductId) { + return [] + } + + return [{}] + }), + findAndCount: jest.fn().mockResolvedValue([[], 0]), + }), + productService: asClass(ProductService), +}) diff --git a/packages/product/src/services/__tests__/product.spec.ts b/packages/product/src/services/__tests__/product.spec.ts index cdeb04d228..ab43e62d5e 100644 --- a/packages/product/src/services/__tests__/product.spec.ts +++ b/packages/product/src/services/__tests__/product.spec.ts @@ -1,29 +1,65 @@ -import { asClass, asValue, createContainer } from "awilix" -import { ProductService } from "@services" - -const container = createContainer() -container.register({ - productRepository: asValue({ - find: jest.fn().mockResolvedValue([]), - findAndCount: jest.fn().mockResolvedValue([[], 0]), - }), - productVariantService: asValue({ - list: jest.fn().mockResolvedValue([]), - }), - productTagService: asValue({ - list: jest.fn().mockResolvedValue([]), - }), - productService: asClass(ProductService), -}) +import { mockContainer, nonExistingProductId } from "../__fixtures__/product" describe("Product service", function () { beforeEach(function () { jest.clearAllMocks() }) + it("should retrieve a product", async function () { + const productService = mockContainer.resolve("productService") + const productRepository = mockContainer.resolve("productRepository") + + const productId = "existing-product" + await productService.retrieve(productId) + expect(productRepository.find).toHaveBeenCalledWith( + { + where: { + id: productId, + }, + options: { + fields: undefined, + limit: 15, + offset: undefined, + populate: [], + withDeleted: undefined, + }, + }, + undefined + ) + }) + + it("should fail to retrieve a product", async function () { + const productService = mockContainer.resolve("productService") + const productRepository = mockContainer.resolve("productRepository") + + const err = await productService + .retrieve(nonExistingProductId) + .catch((e) => e) + + expect(productRepository.find).toHaveBeenCalledWith( + { + where: { + id: nonExistingProductId, + }, + options: { + fields: undefined, + limit: 15, + offset: undefined, + populate: [], + withDeleted: undefined, + }, + }, + undefined + ) + + expect(err.message).toBe( + `Product with id: ${nonExistingProductId} was not found` + ) + }) + it("should list products", async function () { - const productService = container.resolve("productService") - const productRepository = container.resolve("productRepository") + const productService = mockContainer.resolve("productService") + const productRepository = mockContainer.resolve("productRepository") const filters = {} const config = { @@ -32,27 +68,31 @@ describe("Product service", function () { await productService.list(filters, config) - expect(productRepository.find).toHaveBeenCalledWith({ - where: {}, - options: { - fields: undefined, - limit: undefined, - offset: undefined, - populate: [], + expect(productRepository.find).toHaveBeenCalledWith( + { + where: {}, + options: { + fields: undefined, + limit: 15, + offset: undefined, + populate: [], + withDeleted: undefined, + }, }, - }) + undefined + ) }) it("should list products with filters", async function () { - const productService = container.resolve("productService") - const productRepository = container.resolve("productRepository") + const productService = mockContainer.resolve("productService") + const productRepository = mockContainer.resolve("productRepository") const filters = { tags: { value: { $in: ["test"], - } - } + }, + }, } const config = { relations: [], @@ -60,33 +100,37 @@ describe("Product service", function () { await productService.list(filters, config) - expect(productRepository.find).toHaveBeenCalledWith({ - where: { - tags: { - value: { - $in: ["test"] - } + expect(productRepository.find).toHaveBeenCalledWith( + { + where: { + tags: { + value: { + $in: ["test"], + }, + }, + }, + options: { + fields: undefined, + limit: 15, + offset: undefined, + populate: [], + withDeleted: undefined, }, }, - options: { - fields: undefined, - limit: undefined, - offset: undefined, - populate: [], - }, - }) + undefined + ) }) it("should list products with filters and relations", async function () { - const productService = container.resolve("productService") - const productRepository = container.resolve("productRepository") + const productService = mockContainer.resolve("productService") + const productRepository = mockContainer.resolve("productRepository") const filters = { tags: { value: { $in: ["test"], - } - } + }, + }, } const config = { relations: ["tags"], @@ -94,20 +138,62 @@ describe("Product service", function () { await productService.list(filters, config) - expect(productRepository.find).toHaveBeenCalledWith({ - where: { - tags: { - value: { - $in: ["test"] - } + expect(productRepository.find).toHaveBeenCalledWith( + { + where: { + tags: { + value: { + $in: ["test"], + }, + }, + }, + options: { + fields: undefined, + limit: 15, + offset: undefined, + withDeleted: undefined, + populate: ["tags"], }, }, - options: { - fields: undefined, - limit: undefined, - offset: undefined, - populate: ["tags"], + undefined + ) + }) + + it("should list and count the products with filters and relations", async function () { + const productService = mockContainer.resolve("productService") + const productRepository = mockContainer.resolve("productRepository") + + const filters = { + tags: { + value: { + $in: ["test"], + }, }, - }) + } + const config = { + relations: ["tags"], + } + + await productService.listAndCount(filters, config) + + expect(productRepository.findAndCount).toHaveBeenCalledWith( + { + where: { + tags: { + value: { + $in: ["test"], + }, + }, + }, + options: { + fields: undefined, + limit: 15, + offset: undefined, + withDeleted: undefined, + populate: ["tags"], + }, + }, + undefined + ) }) }) diff --git a/packages/product/src/services/index.ts b/packages/product/src/services/index.ts index 3bc71008b9..6772acd4c5 100644 --- a/packages/product/src/services/index.ts +++ b/packages/product/src/services/index.ts @@ -4,3 +4,6 @@ export { default as ProductTagService } from "./product-tag" export { default as ProductVariantService } from "./product-variant" export { default as ProductCollectionService } from "./product-collection" export { default as ProductCategoryService } from "./product-category" +export { default as ProductTypeService } from "./product-type" +export { default as ProductOptionService } from "./product-option" +export { default as ProductImageService } from "./product-image" diff --git a/packages/product/src/services/product-category.ts b/packages/product/src/services/product-category.ts index 8ab228f9c3..d8bb6b70fc 100644 --- a/packages/product/src/services/product-category.ts +++ b/packages/product/src/services/product-category.ts @@ -1,34 +1,99 @@ import { ProductCategory } from "@models" -import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types" -import { buildQuery } from "../utils" +import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types" +import { ModulesSdkUtils, MedusaError, isDefined } from "@medusajs/utils" type InjectedDependencies = { - productCategoryRepository: DAL.RepositoryService + productCategoryRepository: DAL.TreeRepositoryService } -export default class ProductCategoryService { - protected readonly productCategoryRepository_: DAL.RepositoryService +export default class ProductCategoryService< + TEntity extends ProductCategory = ProductCategory +> { + protected readonly productCategoryRepository_: DAL.TreeRepositoryService constructor({ productCategoryRepository }: InjectedDependencies) { this.productCategoryRepository_ = productCategoryRepository } + async retrieve( + productCategoryId: string, + config: FindConfig = {}, + sharedContext?: Context + ): Promise { + if (!isDefined(productCategoryId)) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `"productCategoryId" must be defined` + ) + } + + const queryOptions = ModulesSdkUtils.buildQuery({ + id: productCategoryId, + }, config) + + const transformOptions = { + includeDescendantsTree: true, + } + + const productCategories = await this.productCategoryRepository_.find( + queryOptions, + transformOptions, + sharedContext + ) + + if (!productCategories?.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `ProductCategory with id: ${productCategoryId} was not found` + ) + } + + return productCategories[0] as TEntity + } + async list( filters: ProductTypes.FilterableProductCategoryProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { const transformOptions = { - includeDescendantsTree: filters?.include_descendants_tree || false + includeDescendantsTree: filters?.include_descendants_tree || false, } delete filters.include_descendants_tree - const queryOptions = buildQuery(filters, config) + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) queryOptions.where ??= {} - return await this.productCategoryRepository_.find( + return (await this.productCategoryRepository_.find( queryOptions, transformOptions, + sharedContext + )) as TEntity[] + } + + async listAndCount( + filters: ProductTypes.FilterableProductCategoryProps = {}, + config: FindConfig = {}, + sharedContext?: Context + ): Promise<[TEntity[], number]> { + const transformOptions = { + includeDescendantsTree: filters?.include_descendants_tree || false, + } + delete filters.include_descendants_tree + + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config ) + queryOptions.where ??= {} + + return (await this.productCategoryRepository_.findAndCount( + queryOptions, + transformOptions, + sharedContext + )) as [TEntity[], number] } } diff --git a/packages/product/src/services/product-collection.ts b/packages/product/src/services/product-collection.ts index 49d5efe354..62d02a7d28 100644 --- a/packages/product/src/services/product-collection.ts +++ b/packages/product/src/services/product-collection.ts @@ -1,30 +1,74 @@ import { ProductCollection } from "@models" -import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types" -import { buildQuery } from "../utils" +import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types" +import { ModulesSdkUtils, retrieveEntity } from "@medusajs/utils" type InjectedDependencies = { productCollectionRepository: DAL.RepositoryService } -export default class ProductCollectionService { - protected readonly productCollectionRepository_: DAL.RepositoryService +export default class ProductCollectionService< + TEntity extends ProductCollection = ProductCollection +> { + protected readonly productCollectionRepository_: DAL.TreeRepositoryService constructor({ productCollectionRepository }: InjectedDependencies) { this.productCollectionRepository_ = productCollectionRepository } + async retrieve( + productCollectionId: string, + config: FindConfig = {}, + sharedContext?: Context + ): Promise { + return (await retrieveEntity< + ProductCollection, + ProductTypes.ProductCollectionDTO + >({ + id: productCollectionId, + entityName: ProductCollection.name, + repository: this.productCollectionRepository_, + config, + sharedContext, + })) as TEntity + } + async list( filters: ProductTypes.FilterableProductCollectionProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { - const queryOptions = buildQuery(filters, config) + return (await this.productCollectionRepository_.find( + this.buildListQueryOptions(filters, config), + sharedContext + )) as TEntity[] + } + + async listAndCount( + filters: ProductTypes.FilterableProductCollectionProps = {}, + config: FindConfig = {}, + sharedContext?: Context + ): Promise<[TEntity[], number]> { + return (await this.productCollectionRepository_.findAndCount( + this.buildListQueryOptions(filters, config), + sharedContext + )) as [TEntity[], number] + } + + protected buildListQueryOptions( + filters: ProductTypes.FilterableProductCollectionProps = {}, + config: FindConfig = {} + ) { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + queryOptions.where ??= {} if (filters.title) { queryOptions.where["title"] = { $like: filters.title } } - return await this.productCollectionRepository_.find(queryOptions) + return queryOptions } } diff --git a/packages/product/src/services/product-image.ts b/packages/product/src/services/product-image.ts new file mode 100644 index 0000000000..7149c7c0ca --- /dev/null +++ b/packages/product/src/services/product-image.ts @@ -0,0 +1,26 @@ +import { Image } from "@models" +import { Context, DAL } from "@medusajs/types" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" +import { doNotForceTransaction } from "../utils" +import { ProductImageRepository } from "@repositories" + +type InjectedDependencies = { + productImageRepository: DAL.RepositoryService +} + +export default class ProductImageService { + protected readonly productImageRepository_: DAL.RepositoryService + + constructor({ productImageRepository }: InjectedDependencies) { + this.productImageRepository_ = productImageRepository + } + + @InjectTransactionManager(doNotForceTransaction, "productImageRepository_") + async upsert( + urls: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await (this.productImageRepository_ as ProductImageRepository) + .upsert!(urls, sharedContext)) as TEntity[] + } +} diff --git a/packages/product/src/services/product-module-service.ts b/packages/product/src/services/product-module-service.ts index 785b026c0f..8c741c9474 100644 --- a/packages/product/src/services/product-module-service.ts +++ b/packages/product/src/services/product-module-service.ts @@ -1,66 +1,105 @@ import { ProductCategoryService, ProductCollectionService, + ProductOptionService, ProductService, ProductTagService, + ProductTypeService, ProductVariantService, } from "@services" import { + Image, Product, ProductCategory, ProductCollection, + ProductOption, ProductTag, + ProductType, ProductVariant, } from "@models" -import { FindConfig, ProductTypes, SharedContext } from "@medusajs/types" +import { + Context, + CreateProductOnlyDTO, + DAL, + FindConfig, + InternalModuleDeclaration, + ProductTypes, +} from "@medusajs/types" +import ProductImageService from "./product-image" +import { + InjectTransactionManager, + isDefined, + isString, + kebabCase, + MedusaContext, +} from "@medusajs/utils" +import { shouldForceTransaction } from "../utils" type InjectedDependencies = { + baseRepository: DAL.RepositoryService productService: ProductService - productVariantService: ProductVariantService + productVariantService: ProductVariantService productTagService: ProductTagService productCategoryService: ProductCategoryService productCollectionService: ProductCollectionService + productImageService: ProductImageService + productTypeService: ProductTypeService + productOptionService: ProductOptionService } export default class ProductModuleService< - TProduct = Product, - TProductVariant = ProductVariant, - TProductTag = ProductTag, - TProductCollection = ProductCollection, - TProductCategory = ProductCategory -> implements - ProductTypes.IProductModuleService< - TProduct, - TProductVariant, - TProductTag, - TProductCollection, - TProductCategory - > + TProduct extends Product = Product, + TProductVariant extends ProductVariant = ProductVariant, + TProductTag extends ProductTag = ProductTag, + TProductCollection extends ProductCollection = ProductCollection, + TProductCategory extends ProductCategory = ProductCategory, + TProductImage extends Image = Image, + TProductType extends ProductType = ProductType, + TProductOption extends ProductOption = ProductOption +> implements ProductTypes.IProductModuleService { + protected baseRepository_: DAL.RepositoryService protected readonly productService_: ProductService - protected readonly productVariantService: ProductVariantService - protected readonly productCategoryService: ProductCategoryService - protected readonly productTagService: ProductTagService - protected readonly productCollectionService: ProductCollectionService + protected readonly productVariantService_: ProductVariantService< + TProductVariant, + TProduct + > + protected readonly productCategoryService_: ProductCategoryService + protected readonly productTagService_: ProductTagService + protected readonly productCollectionService_: ProductCollectionService + protected readonly productImageService_: ProductImageService + protected readonly productTypeService_: ProductTypeService + protected readonly productOptionService_: ProductOptionService - constructor({ - productService, - productVariantService, - productTagService, - productCategoryService, - productCollectionService, - }: InjectedDependencies) { + constructor( + { + baseRepository, + productService, + productVariantService, + productTagService, + productCategoryService, + productCollectionService, + productImageService, + productTypeService, + productOptionService, + }: InjectedDependencies, + protected readonly moduleDeclaration: InternalModuleDeclaration + ) { + this.baseRepository_ = baseRepository this.productService_ = productService - this.productVariantService = productVariantService - this.productTagService = productTagService - this.productCategoryService = productCategoryService - this.productCollectionService = productCollectionService + this.productVariantService_ = productVariantService + this.productTagService_ = productTagService + this.productCategoryService_ = productCategoryService + this.productCollectionService_ = productCollectionService + this.productImageService_ = productImageService + this.productTypeService_ = productTypeService + this.productOptionService_ = productOptionService } async list( filters: ProductTypes.FilterableProductProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { const products = await this.productService_.list( filters, @@ -71,10 +110,22 @@ export default class ProductModuleService< return JSON.parse(JSON.stringify(products)) } + async retrieve( + productId: string, + sharedContext?: Context + ): Promise { + const product = await this.productService_.retrieve( + productId, + sharedContext + ) + + return JSON.parse(JSON.stringify(product)) + } + async listAndCount( filters: ProductTypes.FilterableProductProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise<[ProductTypes.ProductDTO[], number]> { const [products, count] = await this.productService_.listAndCount( filters, @@ -85,12 +136,26 @@ export default class ProductModuleService< return [JSON.parse(JSON.stringify(products)), count] } + async retrieveVariant( + productVariantId: string, + config: FindConfig = {}, + sharedContext?: Context + ): Promise { + const productVariant = await this.productVariantService_.retrieve( + productVariantId, + config, + sharedContext + ) + + return JSON.parse(JSON.stringify(productVariant)) + } + async listVariants( filters: ProductTypes.FilterableProductVariantProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { - const variants = await this.productVariantService.list( + const variants = await this.productVariantService_.list( filters, config, sharedContext @@ -99,12 +164,26 @@ export default class ProductModuleService< return JSON.parse(JSON.stringify(variants)) } + async listAndCountVariants( + filters: ProductTypes.FilterableProductVariantProps = {}, + config: FindConfig = {}, + sharedContext?: Context + ): Promise<[ProductTypes.ProductVariantDTO[], number]> { + const [variants, count] = await this.productVariantService_.listAndCount( + filters, + config, + sharedContext + ) + + return [JSON.parse(JSON.stringify(variants)), count] + } + async listTags( filters: ProductTypes.FilterableProductTagProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { - const tags = await this.productTagService.list( + const tags = await this.productTagService_.list( filters, config, sharedContext @@ -113,12 +192,26 @@ export default class ProductModuleService< return JSON.parse(JSON.stringify(tags)) } + async retrieveCollection( + productCollectionId: string, + config: FindConfig = {}, + sharedContext?: Context + ): Promise { + const productCollection = await this.productCollectionService_.retrieve( + productCollectionId, + config, + sharedContext + ) + + return JSON.parse(JSON.stringify(productCollection)) + } + async listCollections( filters: ProductTypes.FilterableProductCollectionProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { - const collections = await this.productCollectionService.list( + const collections = await this.productCollectionService_.list( filters, config, sharedContext @@ -127,12 +220,40 @@ export default class ProductModuleService< return JSON.parse(JSON.stringify(collections)) } + async listAndCountCollections( + filters: ProductTypes.FilterableProductCollectionProps = {}, + config: FindConfig = {}, + sharedContext?: Context + ): Promise<[ProductTypes.ProductCollectionDTO[], number]> { + const collections = await this.productCollectionService_.listAndCount( + filters, + config, + sharedContext + ) + + return JSON.parse(JSON.stringify(collections)) + } + + async retrieveCategory( + productCategoryId: string, + config: FindConfig = {}, + sharedContext?: Context + ): Promise { + const productCategory = await this.productCategoryService_.retrieve( + productCategoryId, + config, + sharedContext + ) + + return JSON.parse(JSON.stringify(productCategory)) + } + async listCategories( filters: ProductTypes.FilterableProductCategoryProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { - const categories = await this.productCategoryService.list( + const categories = await this.productCategoryService_.list( filters, config, sharedContext @@ -140,4 +261,199 @@ export default class ProductModuleService< return JSON.parse(JSON.stringify(categories)) } + + async listAndCountCategories( + filters: ProductTypes.FilterableProductCategoryProps = {}, + config: FindConfig = {}, + sharedContext?: Context + ): Promise<[ProductTypes.ProductCategoryDTO[], number]> { + const categories = await this.productCategoryService_.listAndCount( + filters, + config, + sharedContext + ) + + return JSON.parse(JSON.stringify(categories)) + } + + async create(data: ProductTypes.CreateProductDTO[], sharedContext?: Context) { + const products = await this.create_(data, sharedContext) + + return this.baseRepository_.serialize< + TProduct[], + ProductTypes.ProductDTO[] + >(products, { + populate: true, + }) + } + + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + protected async create_( + data: ProductTypes.CreateProductDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const productVariantsMap = new Map< + string, + ProductTypes.CreateProductVariantDTO[] + >() + const productOptionsMap = new Map< + string, + ProductTypes.CreateProductOptionDTO[] + >() + + const productsData = await Promise.all( + data.map(async (product) => { + const productData = { ...product } + if (!productData.handle) { + productData.handle = kebabCase(product.title) + } + + const variants = productData.variants + const options = productData.options + delete productData.options + delete productData.variants + + productVariantsMap.set(productData.handle!, variants ?? []) + productOptionsMap.set(productData.handle!, options ?? []) + + if (!productData.thumbnail && productData.images?.length) { + productData.thumbnail = isString(productData.images[0]) + ? (productData.images[0] as string) + : (productData.images[0] as { url: string }).url + } + + if (productData.is_giftcard) { + productData.discountable = false + } + + if (productData.images?.length) { + productData.images = await this.productImageService_.upsert( + productData.images.map((image) => + isString(image) ? image : image.url + ), + sharedContext + ) + } + + if (productData.tags?.length) { + productData.tags = await this.productTagService_.upsert( + productData.tags, + sharedContext + ) + } + + if (isDefined(productData.type)) { + productData.type_id = ( + await this.productTypeService_.upsert( + [productData.type as ProductTypes.CreateProductTypeDTO], + sharedContext + ) + )?.[0]!.id + } + + return productData as CreateProductOnlyDTO + }) + ) + + const products = await this.productService_.create( + productsData, + sharedContext + ) + + const productByHandleMap = new Map( + products.map((product) => [product.handle!, product]) + ) + + const productOptionsData = [...productOptionsMap] + .map(([handle, options]) => { + return options.map((option) => { + return { + ...option, + product: productByHandleMap.get(handle)!, + } + }) + }) + .flat() + + const productOptions = await this.productOptionService_.create( + productOptionsData, + sharedContext + ) + + for (const variants of productVariantsMap.values()) { + variants.forEach((variant) => { + variant.options = variant.options?.map((option, index) => { + const productOption = productOptions[index] + return { + option: productOption, + value: option.value, + } + }) + }) + } + + await Promise.all( + [...productVariantsMap].map(async ([handle, variants]) => { + return await this.productVariantService_.create( + productByHandleMap.get(handle)!, + variants as unknown as ProductTypes.CreateProductVariantOnlyDTO[], + sharedContext + ) + }) + ) + + return products + } + + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + async delete( + productIds: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.productService_.delete(productIds, sharedContext) + } + + async softDelete( + productIds: string[], + sharedContext: Context = {} + ): Promise { + const products = await this.softDelete_(productIds, sharedContext) + + return this.baseRepository_.serialize< + TProduct[], + ProductTypes.ProductDTO[] + >(products, { + populate: true, + }) + } + + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + protected async softDelete_( + productIds: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return await this.productService_.softDelete(productIds, sharedContext) + } + + async restore( + productIds: string[], + sharedContext: Context = {} + ): Promise { + const products = await this.restore_(productIds, sharedContext) + + return this.baseRepository_.serialize< + TProduct[], + ProductTypes.ProductDTO[] + >(products, { + populate: true, + }) + } + + @InjectTransactionManager(shouldForceTransaction, "baseRepository_") + async restore_( + productIds: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return await this.productService_.restore(productIds, sharedContext) + } } diff --git a/packages/product/src/services/product-option.ts b/packages/product/src/services/product-option.ts new file mode 100644 index 0000000000..50bf2ff9b3 --- /dev/null +++ b/packages/product/src/services/product-option.ts @@ -0,0 +1,32 @@ +import { ProductOption } from "@models" +import { Context, DAL, ProductTypes } from "@medusajs/types" +import { ProductOptionRepository } from "@repositories" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" +import { doNotForceTransaction } from "../utils" + +type InjectedDependencies = { + productOptionRepository: DAL.RepositoryService +} + +export default class ProductOptionService< + TEntity extends ProductOption = ProductOption +> { + protected readonly productOptionRepository_: DAL.RepositoryService + + constructor({ productOptionRepository }: InjectedDependencies) { + this.productOptionRepository_ = + productOptionRepository as ProductOptionRepository + } + + @InjectTransactionManager(doNotForceTransaction, "productOptionRepository_") + async create( + data: ProductTypes.CreateProductOptionOnlyDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await ( + this.productOptionRepository_ as ProductOptionRepository + ).create(data, { + transactionManager: sharedContext.transactionManager, + })) as TEntity[] + } +} diff --git a/packages/product/src/services/product-tag.ts b/packages/product/src/services/product-tag.ts index 56b42d0ec9..132c4dd0ea 100644 --- a/packages/product/src/services/product-tag.ts +++ b/packages/product/src/services/product-tag.ts @@ -1,13 +1,27 @@ import { ProductTag } from "@models" -import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types" -import { buildQuery } from "../utils" +import { + Context, + CreateProductTagDTO, + DAL, + FindConfig, + ProductTypes, +} from "@medusajs/types" +import { + InjectTransactionManager, + MedusaContext, + ModulesSdkUtils, +} from "@medusajs/utils" +import { doNotForceTransaction } from "../utils" +import { ProductTagRepository } from "@repositories" type InjectedDependencies = { productTagRepository: DAL.RepositoryService } -export default class ProductTagService { - protected readonly productTagRepository_: DAL.RepositoryService +export default class ProductTagService< + TEntity extends ProductTag = ProductTag +> { + protected readonly productTagRepository_: DAL.RepositoryService constructor({ productTagRepository }: InjectedDependencies) { this.productTagRepository_ = productTagRepository @@ -16,14 +30,28 @@ export default class ProductTagService { async list( filters: ProductTypes.FilterableProductTagProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { - const queryOptions = buildQuery(filters, config) + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) if (filters.value) { queryOptions.where["value"] = { $ilike: filters.value } } - return await this.productTagRepository_.find(queryOptions) + return (await this.productTagRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] + } + + @InjectTransactionManager(doNotForceTransaction, "productTagRepository_") + async upsert( + tags: CreateProductTagDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await (this.productTagRepository_ as ProductTagRepository).upsert!( + tags, + sharedContext + )) as TEntity[] } } diff --git a/packages/product/src/services/product-type.ts b/packages/product/src/services/product-type.ts new file mode 100644 index 0000000000..60f25106ee --- /dev/null +++ b/packages/product/src/services/product-type.ts @@ -0,0 +1,28 @@ +import { ProductType } from "@models" +import { Context, CreateProductTypeDTO, DAL } from "@medusajs/types" +import { InjectTransactionManager, MedusaContext } from "@medusajs/utils" +import { doNotForceTransaction } from "../utils" +import { ProductTypeRepository } from "@repositories" + +type InjectedDependencies = { + productTypeRepository: DAL.RepositoryService +} + +export default class ProductTypeService< + TEntity extends ProductType = ProductType +> { + protected readonly productTypeRepository_: DAL.RepositoryService + + constructor({ productTypeRepository }: InjectedDependencies) { + this.productTypeRepository_ = productTypeRepository + } + + @InjectTransactionManager(doNotForceTransaction, "productTypeRepository_") + async upsert( + types: CreateProductTypeDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return (await (this.productTypeRepository_ as ProductTypeRepository) + .upsert!(types, sharedContext)) as TEntity[] + } +} diff --git a/packages/product/src/services/product-variant.ts b/packages/product/src/services/product-variant.ts index 729aea47b5..d04c7b6240 100644 --- a/packages/product/src/services/product-variant.ts +++ b/packages/product/src/services/product-variant.ts @@ -1,24 +1,115 @@ -import { ProductVariant } from "@models" -import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types" -import { buildQuery } from "../utils" +import { Product, ProductVariant } from "@models" +import { Context, DAL, FindConfig, ProductTypes } from "@medusajs/types" +import { + InjectTransactionManager, + isString, + MedusaContext, + ModulesSdkUtils, + retrieveEntity, +} from "@medusajs/utils" + +import ProductService from "./product" +import { doNotForceTransaction } from "../utils" +import { ProductVariantRepository } from "@repositories" type InjectedDependencies = { productVariantRepository: DAL.RepositoryService + productService: ProductService } -export default class ProductVariantService { - protected readonly productVariantRepository_: DAL.RepositoryService +export default class ProductVariantService< + TEntity extends ProductVariant = ProductVariant, + TProduct extends Product = Product +> { + protected readonly productVariantRepository_: DAL.RepositoryService + protected readonly productService_: ProductService - constructor({ productVariantRepository }: InjectedDependencies) { + constructor({ + productVariantRepository, + productService, + }: InjectedDependencies) { this.productVariantRepository_ = productVariantRepository + this.productService_ = productService + } + + async retrieve( + productVariantId: string, + config: FindConfig = {}, + sharedContext?: Context + ): Promise { + return (await retrieveEntity< + ProductVariant, + ProductTypes.ProductVariantDTO + >({ + id: productVariantId, + entityName: ProductVariant.name, + repository: this.productVariantRepository_, + config, + sharedContext, + })) as TEntity } async list( filters: ProductTypes.FilterableProductVariantProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { - const queryOptions = buildQuery(filters, config) - return await this.productVariantRepository_.find(queryOptions) + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.productVariantRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] + } + + async listAndCount( + filters: ProductTypes.FilterableProductVariantProps = {}, + config: FindConfig = {}, + sharedContext?: Context + ): Promise<[TEntity[], number]> { + const queryOptions = ModulesSdkUtils.buildQuery( + filters, + config + ) + + return (await this.productVariantRepository_.findAndCount( + queryOptions, + sharedContext + )) as [TEntity[], number] + } + + @InjectTransactionManager(doNotForceTransaction, "productVariantRepository_") + async create( + productOrId: TProduct | string, + data: ProductTypes.CreateProductVariantOnlyDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + let product = productOrId as unknown as Product + + if (isString(productOrId)) { + product = await this.productService_.retrieve( + productOrId as string, + sharedContext + ) + } + + let computedRank = product.variants.toArray().length + + const data_ = [...data] + data_.forEach((variant) => { + Object.assign(variant, { + variant_rank: computedRank++, + product, + }) + }) + + return (await ( + this.productVariantRepository_ as ProductVariantRepository + ).create(data_, { + transactionManager: sharedContext.transactionManager, + })) as TEntity[] } } diff --git a/packages/product/src/services/product.ts b/packages/product/src/services/product.ts index 2a5666c6cf..b31b865aff 100644 --- a/packages/product/src/services/product.ts +++ b/packages/product/src/services/product.ts @@ -1,25 +1,55 @@ -import { ProductTagService, ProductVariantService } from "@services" import { Product } from "@models" -import { DAL, FindConfig, ProductTypes, SharedContext } from "@medusajs/types" -import { buildQuery } from "../utils" +import { + Context, + DAL, + FindConfig, + ProductStatus, + ProductTypes, + WithRequiredProperty, +} from "@medusajs/types" +import { + InjectTransactionManager, + MedusaContext, + MedusaError, + ModulesSdkUtils, +} from "@medusajs/utils" +import { ProductRepository } from "@repositories" +import { doNotForceTransaction } from "../utils" type InjectedDependencies = { productRepository: DAL.RepositoryService - productVariantService: ProductVariantService - productTagService: ProductTagService } -export default class ProductService { - protected readonly productRepository_: DAL.RepositoryService +export default class ProductService { + protected readonly productRepository_: DAL.RepositoryService constructor({ productRepository }: InjectedDependencies) { this.productRepository_ = productRepository } + async retrieve(productId: string, sharedContext?: Context): Promise { + const queryOptions = ModulesSdkUtils.buildQuery({ + id: productId, + }) + const product = await this.productRepository_.find( + queryOptions, + sharedContext + ) + + if (!product?.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product with id: ${productId} was not found` + ) + } + + return product[0] as TEntity + } + async list( filters: ProductTypes.FilterableProductProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise { if (filters.category_ids) { if (Array.isArray(filters.category_ids)) { @@ -34,14 +64,17 @@ export default class ProductService { delete filters.category_ids } - const queryOptions = buildQuery(filters, config) - return await this.productRepository_.find(queryOptions) + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) + return (await this.productRepository_.find( + queryOptions, + sharedContext + )) as TEntity[] } async listAndCount( filters: ProductTypes.FilterableProductProps = {}, config: FindConfig = {}, - sharedContext?: SharedContext + sharedContext?: Context ): Promise<[TEntity[], number]> { if (filters.category_ids) { if (Array.isArray(filters.category_ids)) { @@ -56,7 +89,60 @@ export default class ProductService { delete filters.category_ids } - const queryOptions = buildQuery(filters, config) - return await this.productRepository_.findAndCount(queryOptions) + const queryOptions = ModulesSdkUtils.buildQuery(filters, config) + return (await this.productRepository_.findAndCount( + queryOptions, + sharedContext + )) as [TEntity[], number] + } + + @InjectTransactionManager(doNotForceTransaction, "productRepository_") + async create( + data: ProductTypes.CreateProductOnlyDTO[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + data.forEach((product) => { + product.status ??= ProductStatus.DRAFT + }) + + return (await (this.productRepository_ as ProductRepository).create( + data as WithRequiredProperty< + ProductTypes.CreateProductOnlyDTO, + "status" + >[], + { + transactionManager: sharedContext.transactionManager, + } + )) as TEntity[] + } + + @InjectTransactionManager(doNotForceTransaction, "productRepository_") + async delete( + ids: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + await this.productRepository_.delete(ids, { + transactionManager: sharedContext.transactionManager, + }) + } + + @InjectTransactionManager(doNotForceTransaction, "productRepository_") + async softDelete( + productIds: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return await this.productRepository_.softDelete(productIds, { + transactionManager: sharedContext.transactionManager, + }) + } + + @InjectTransactionManager(doNotForceTransaction, "productRepository_") + async restore( + productIds: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + return await this.productRepository_.restore(productIds, { + transactionManager: sharedContext.transactionManager, + }) } } diff --git a/packages/product/src/types/index.ts b/packages/product/src/types/index.ts index 8bd3ca130b..17d032b4c3 100644 --- a/packages/product/src/types/index.ts +++ b/packages/product/src/types/index.ts @@ -1,17 +1,4 @@ -import { Constructor, DAL, IEventBusService } from "@medusajs/types" - -export type ProductServiceInitializeOptions = { - database: { - clientUrl: string - schema?: string - driverOptions?: Record - } -} - -export type ProductServiceInitializeCustomDataLayerOptions = { - manager?: any - repositories?: { [key: string]: Constructor } -} +import { IEventBusService } from "@medusajs/types" export type InitializeModuleInjectableDependencies = { eventBusService?: IEventBusService diff --git a/packages/product/src/utils/create-connection.ts b/packages/product/src/utils/create-connection.ts index 814d83eb1e..f879d274f4 100644 --- a/packages/product/src/utils/create-connection.ts +++ b/packages/product/src/utils/create-connection.ts @@ -1,15 +1,15 @@ import { MikroORM, PostgreSqlDriver } from "@mikro-orm/postgresql" -import { ProductServiceInitializeOptions } from "../types" +import { ModuleServiceInitializeOptions } from "@medusajs/types" export async function createConnection( - database: ProductServiceInitializeOptions["database"], + database: ModuleServiceInitializeOptions["database"], entities: any[] ) { const schema = database.schema || "public" const orm = await MikroORM.init({ discovery: { disableDynamicFileAccess: true }, entities, - debug: process.env.NODE_ENV === "development", + debug: database.debug ?? process.env.NODE_ENV?.startsWith("dev") ?? false, baseDir: process.cwd(), clientUrl: database.clientUrl, schema, diff --git a/packages/product/src/utils/index.ts b/packages/product/src/utils/index.ts index 0d51de5829..f9e23b9cd0 100644 --- a/packages/product/src/utils/index.ts +++ b/packages/product/src/utils/index.ts @@ -1,3 +1,12 @@ -export * from "./query" +import { MODULE_RESOURCE_TYPE } from "@medusajs/types" + export * from "./create-connection" -export * from "./load-database-config" +export * from "./soft-deletable" + +export function shouldForceTransaction(target: any): boolean { + return target.moduleDeclaration?.resources === MODULE_RESOURCE_TYPE.ISOLATED +} + +export function doNotForceTransaction(): boolean { + return false +} diff --git a/packages/product/src/utils/soft-deletable.ts b/packages/product/src/utils/soft-deletable.ts new file mode 100644 index 0000000000..83c4328111 --- /dev/null +++ b/packages/product/src/utils/soft-deletable.ts @@ -0,0 +1,23 @@ +// TODO: Should we create a mikro orm specific package for this and the base repository? + +import { Filter } from "@mikro-orm/core" +import { DAL } from "@medusajs/types" + +interface FilterArguments { + withDeleted?: boolean +} + +export const SoftDeletable = (): ClassDecorator => { + return Filter({ + name: DAL.SoftDeletableFilterKey, + cond: ({ withDeleted }: FilterArguments = {}) => { + if (withDeleted) { + return {} + } + return { + deleted_at: null, + } + }, + default: true, + }) +} diff --git a/packages/types/package.json b/packages/types/package.json index 0f23f169f0..b5c8b87ea9 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -3,6 +3,7 @@ "version": "1.8.10", "description": "Medusa Types definition", "main": "dist/index.js", + "types": "dist/index.d.ts", "repository": { "type": "git", "url": "https://github.com/medusajs/medusa", diff --git a/packages/types/src/common/common.ts b/packages/types/src/common/common.ts index dd47065706..230fbb822a 100644 --- a/packages/types/src/common/common.ts +++ b/packages/types/src/common/common.ts @@ -41,11 +41,12 @@ export type Writable = { } export interface FindConfig { - select?: (keyof Entity)[] + select?: (keyof Entity | string)[] skip?: number take?: number relations?: string[] order?: { [K: string]: "ASC" | "DESC" } + withDeleted?: boolean } export type ExtendedFindConfig = ( diff --git a/packages/types/src/dal/index.ts b/packages/types/src/dal/index.ts index fd52c3e0ae..3309fec05d 100644 --- a/packages/types/src/dal/index.ts +++ b/packages/types/src/dal/index.ts @@ -21,4 +21,6 @@ export type FindOptions = { options?: OptionsQuery } +export const SoftDeletableFilterKey = "softDeletable" + export * from "./repository-service" diff --git a/packages/types/src/dal/repository-service.ts b/packages/types/src/dal/repository-service.ts index 33817b9988..939bda898a 100644 --- a/packages/types/src/dal/repository-service.ts +++ b/packages/types/src/dal/repository-service.ts @@ -1,5 +1,6 @@ import { FindOptions } from "./index" import { RepositoryTransformOptions } from "../common" +import { Context } from "../shared-context" /** * Data access layer (DAL) interface to implements for any repository service. @@ -7,6 +8,54 @@ import { RepositoryTransformOptions } from "../common" * ORM directly and allows to switch to another ORM without changing the business logic. */ export interface RepositoryService { - find(options?: FindOptions, transformOptions?: RepositoryTransformOptions): Promise - findAndCount(options?: FindOptions, transformOptions?: RepositoryTransformOptions): Promise<[T[], number]> + transaction( + task: (transactionManager: unknown) => Promise, + context?: { + isolationLevel?: string + transaction?: unknown + enableNestedTransactions?: boolean + } + ): Promise + + serialize( + data: TData, + options?: TOptions + ): Promise + + serialize( + data: TData[], + options?: TOptions + ): Promise + + find(options?: FindOptions, context?: Context): Promise + + findAndCount( + options?: FindOptions, + context?: Context + ): Promise<[T[], number]> + + // Only required for some repositories + upsert?(data: any, context?: Context): Promise + + create(data: unknown[], context?: Context): Promise + + delete(ids: string[], context?: Context): Promise + + softDelete(ids: string[], context?: Context): Promise + + restore(ids: string[], context?: Context): Promise +} + +export interface TreeRepositoryService extends RepositoryService { + find( + options?: FindOptions, + transformOptions?: RepositoryTransformOptions, + context?: Context + ): Promise + + findAndCount( + options?: FindOptions, + transformOptions?: RepositoryTransformOptions, + context?: Context + ): Promise<[T[], number]> } diff --git a/packages/types/src/inventory/inventory.ts b/packages/types/src/inventory/inventory.ts index d5ae199874..3922b4bd65 100644 --- a/packages/types/src/inventory/inventory.ts +++ b/packages/types/src/inventory/inventory.ts @@ -56,6 +56,7 @@ export interface IInventoryService { context?: SharedContext ): Promise + // TODO make it bulk createInventoryItem( input: CreateInventoryItemInput, context?: SharedContext @@ -95,6 +96,7 @@ export interface IInventoryService { context?: SharedContext ): Promise + // TODO make it bulk deleteInventoryItem( inventoryItemId: string, context?: SharedContext diff --git a/packages/types/src/modules-sdk/index.ts b/packages/types/src/modules-sdk/index.ts index b6f14f7c48..2ed4ab48f9 100644 --- a/packages/types/src/modules-sdk/index.ts +++ b/packages/types/src/modules-sdk/index.ts @@ -1,5 +1,6 @@ import { MedusaContainer } from "../common" import { Logger } from "../logger" +import { RepositoryService } from "../dal" export type Constructor = new (...args: any[]) => T export * from "../common/medusa-container" @@ -93,3 +94,17 @@ export type ModuleExports = { moduleDeclaration?: InternalModuleDeclaration ): Promise } + +export interface ModuleServiceInitializeOptions { + database: { + clientUrl: string + schema?: string + driverOptions?: Record + debug?: boolean + } +} + +export type ModuleServiceInitializeCustomDataLayerOptions = { + manager?: any + repositories?: { [key: string]: Constructor } +} diff --git a/packages/types/src/product/common.ts b/packages/types/src/product/common.ts index b44c8f9df7..3d49dd09ee 100644 --- a/packages/types/src/product/common.ts +++ b/packages/types/src/product/common.ts @@ -34,6 +34,7 @@ export interface ProductDTO { tags: ProductTagDTO[] variants: ProductVariantDTO[] options: ProductOptionDTO[] + images: ProductImageDTO[] discountable?: boolean external_id?: string | null created_at?: string | Date @@ -86,7 +87,7 @@ export interface ProductTagDTO { id: string value: string metadata?: Record | null - products: ProductDTO[] + products?: ProductDTO[] } export interface ProductCollectionDTO { @@ -95,6 +96,7 @@ export interface ProductCollectionDTO { handle: string metadata?: Record | null deleted_at?: string | Date + products?: ProductDTO[] } export interface ProductTypeDTO { @@ -113,6 +115,13 @@ export interface ProductOptionDTO { deleted_at?: string | Date } +export interface ProductImageDTO { + id: string + url: string + metadata?: Record | null + deleted_at?: string | Date +} + export interface ProductOptionValueDTO { id: string value: string @@ -164,3 +173,126 @@ export interface FilterableProductCategoryProps is_internal?: boolean include_descendants_tree?: boolean } + +/** + * Write DTO (module API input) + */ + +export interface CreateProductTypeDTO { + id?: string + value: string +} + +export interface CreateProductTagDTO { + id?: string + value: string +} + +export interface CreateProductOptionDTO { + title: string +} + +export interface CreateProductVariantOptionDTO { + value: string +} + +export interface CreateProductVariantDTO { + title: string + sku?: string + barcode?: string + ean?: string + upc?: string + allow_backorder?: boolean + inventory_quantity?: number + manage_inventory?: boolean + hs_code?: string + origin_country?: string + mid_code?: string + material?: string + weight?: number + length?: number + height?: number + width?: number + options?: CreateProductVariantOptionDTO[] + metadata?: Record +} + +export interface CreateProductDTO { + title: string + subtitle?: string + description?: string + is_giftcard?: boolean + discountable?: boolean + images?: string[] | { id?: string; url: string }[] + thumbnail?: string + handle?: string + status?: ProductStatus + type?: CreateProductTypeDTO + type_id?: string + collection_id?: string + tags?: CreateProductTagDTO[] + // sales_channel + categories?: { id: string }[] + options?: CreateProductOptionDTO[] + variants?: CreateProductVariantDTO[] + width?: number + height?: number + length?: number + weight?: number + origin_country?: string + hs_code?: string + material?: string + mid_code?: string + metadata?: Record +} + +export interface CreateProductOnlyDTO { + title: string + subtitle?: string + description?: string + is_giftcard?: boolean + discountable?: boolean + images?: { id?: string; url: string }[] + thumbnail?: string + handle?: string + status?: ProductStatus + collection_id?: string + width?: number + height?: number + length?: number + weight?: number + origin_country?: string + hs_code?: string + material?: string + mid_code?: string + metadata?: Record + tags?: { id: string }[] + categories?: { id: string }[] + type_id?: string +} + +export interface CreateProductVariantOnlyDTO { + title: string + sku?: string + barcode?: string + ean?: string + upc?: string + allow_backorder?: boolean + inventory_quantity?: number + manage_inventory?: boolean + hs_code?: string + origin_country?: string + mid_code?: string + material?: string + weight?: number + length?: number + height?: number + width?: number + options?: (CreateProductVariantOptionDTO & { option: any })[] + metadata?: Record +} + +export interface CreateProductOptionOnlyDTO { + product: { id: string } + title: string +} diff --git a/packages/types/src/product/service.ts b/packages/types/src/product/service.ts index 3d8914b16c..d2ba499628 100644 --- a/packages/types/src/product/service.ts +++ b/packages/types/src/product/service.ts @@ -1,4 +1,5 @@ import { + CreateProductDTO, FilterableProductCategoryProps, FilterableProductCollectionProps, FilterableProductProps, @@ -11,48 +12,94 @@ import { ProductVariantDTO, } from "./common" import { FindConfig } from "../common" -import { SharedContext } from "../shared-context" +import { Context } from "../shared-context" + +export interface IProductModuleService { + retrieve(productId: string, sharedContext?: Context): Promise -export interface IProductModuleService< - TProduct = any, - TProductVariant = any, - TProductTag = any, - TProductCollection = any, - TProductCategory = any -> { list( filters?: FilterableProductProps, config?: FindConfig, - sharedContext?: SharedContext + sharedContext?: Context ): Promise listAndCount( filters?: FilterableProductProps, config?: FindConfig, - sharedContext?: SharedContext + sharedContext?: Context ): Promise<[ProductDTO[], number]> listTags( filters?: FilterableProductTagProps, config?: FindConfig, - sharedContext?: SharedContext + sharedContext?: Context ): Promise + retrieveVariant( + productVariantId: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + listVariants( filters?: FilterableProductVariantProps, config?: FindConfig, - sharedContext?: SharedContext + sharedContext?: Context ): Promise + listAndCountVariants( + filters?: FilterableProductVariantProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[ProductVariantDTO[], number]> + + retrieveCollection( + productCollectionId: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + listCollections( filters?: FilterableProductCollectionProps, config?: FindConfig, - sharedContext?: SharedContext + sharedContext?: Context ): Promise + listAndCountCollections( + filters?: FilterableProductCollectionProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[ProductCollectionDTO[], number]> + + retrieveCategory( + productCategoryId: string, + config?: FindConfig, + sharedContext?: Context + ): Promise + listCategories( filters?: FilterableProductCategoryProps, config?: FindConfig, - sharedContext?: SharedContext + sharedContext?: Context ): Promise + + listAndCountCategories( + filters?: FilterableProductCategoryProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[ProductCategoryDTO[], number]> + + create( + data: CreateProductDTO[], + sharedContext?: Context + ): Promise + + delete(productIds: string[], sharedContext?: Context): Promise + + softDelete( + productIds: string[], + sharedContext?: Context + ): Promise + + restore(productIds: string[], sharedContext?: Context): Promise } diff --git a/packages/types/src/shared-context.ts b/packages/types/src/shared-context.ts index 64d8806983..81c925ee0d 100644 --- a/packages/types/src/shared-context.ts +++ b/packages/types/src/shared-context.ts @@ -3,3 +3,9 @@ import { EntityManager } from "typeorm" export type SharedContext = { transactionManager?: EntityManager } + +export type Context = { + transactionManager?: TManager + isolationLevel?: string + enableNestedTransactions?: boolean +} diff --git a/packages/utils/package.json b/packages/utils/package.json index cad3c836ad..7ec7404390 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -3,6 +3,7 @@ "version": "1.9.2", "description": "Medusa utilities functions shared by Medusa core and Modules", "main": "dist/index.js", + "types": "dist/index.d.ts", "repository": { "type": "git", "url": "https://github.com/medusajs/medusa", @@ -22,6 +23,7 @@ "cross-env": "^5.2.1", "express": "^4.18.2", "jest": "^25.5.4", + "rimraf": "^5.0.1", "ts-jest": "^25.5.1", "typescript": "^4.4.4" }, @@ -32,7 +34,7 @@ }, "scripts": { "prepare": "cross-env NODE_ENV=production yarn run build", - "build": "tsc --build", + "build": "rimraf dist && tsc --build", "watch": "tsc --build --watch", "test": "jest" } diff --git a/packages/utils/src/bundles.ts b/packages/utils/src/bundles.ts index dca89940d2..1736971518 100644 --- a/packages/utils/src/bundles.ts +++ b/packages/utils/src/bundles.ts @@ -1,4 +1,4 @@ -export * as DecoratorUtils from "./decorators"; -export * as EventBusUtils from "./event-bus"; -export * as SearchUtils from "./search"; - +export * as DecoratorUtils from "./decorators" +export * as EventBusUtils from "./event-bus" +export * as SearchUtils from "./search" +export * as ModulesSdkUtils from "./modules-sdk" diff --git a/packages/utils/src/common/deduplicate.ts b/packages/utils/src/common/deduplicate.ts new file mode 100644 index 0000000000..3b161a72f8 --- /dev/null +++ b/packages/utils/src/common/deduplicate.ts @@ -0,0 +1,3 @@ +export function deduplicate(collection: T[]): T[] { + return [...new Set(collection)] +} diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index 7270860f2a..2d949d143c 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -1,4 +1,5 @@ export * from "./build-query" +export * from "./deduplicate" export * from "./errors" export * from "./generate-entity-id" export * from "./get-config-file" diff --git a/packages/utils/src/decorators/context-parameter.ts b/packages/utils/src/decorators/context-parameter.ts index 15144b842a..ffa3afdd99 100644 --- a/packages/utils/src/decorators/context-parameter.ts +++ b/packages/utils/src/decorators/context-parameter.ts @@ -4,16 +4,7 @@ export function MedusaContext() { propertyKey: string | symbol, parameterIndex: number ) { - if (!target.MedusaContextIndex_) { - target.MedusaContextIndex_ = {} - } - - if (propertyKey in target.MedusaContextIndex_) { - throw new Error( - `Only one MedusaContext is allowed on method "${String(propertyKey)}".` - ) - } - + target.MedusaContextIndex_ ??= {} target.MedusaContextIndex_[propertyKey] = parameterIndex } } diff --git a/packages/utils/src/decorators/inject-entity-manager.ts b/packages/utils/src/decorators/inject-entity-manager.ts index 3e7f45c2af..b94eb52b92 100644 --- a/packages/utils/src/decorators/inject-entity-manager.ts +++ b/packages/utils/src/decorators/inject-entity-manager.ts @@ -1,8 +1,8 @@ -import { SharedContext } from "@medusajs/types" +import { Context, SharedContext } from "@medusajs/types" export function InjectEntityManager( shouldForceTransaction: (target: any) => boolean = () => false, - managerProperty: string = "manager_" + managerProperty: string | false = "manager_" ): MethodDecorator { return function ( target: any, @@ -20,18 +20,27 @@ export function InjectEntityManager( const argIndex = target.MedusaContextIndex_[propertyKey] descriptor.value = async function (...args: any[]) { const shouldForceTransactionRes = shouldForceTransaction(target) - const context: SharedContext = args[argIndex] ?? {} + const context: SharedContext | Context = args[argIndex] ?? {} if (!shouldForceTransactionRes && context?.transactionManager) { return await originalMethod.apply(this, args) } - return await this[managerProperty].transaction( + return await (managerProperty === false + ? this + : this[managerProperty] + ).transaction( async (transactionManager) => { args[argIndex] = args[argIndex] ?? {} args[argIndex].transactionManager = transactionManager return await originalMethod.apply(this, args) + }, + { + transaction: context?.transactionManager, + isolationLevel: (context as Context)?.isolationLevel, + enableNestedTransactions: + (context as Context).enableNestedTransactions ?? false, } ) } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 0c75dd12f0..6cd1081af6 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -3,4 +3,5 @@ export * from "./cli" export * from "./common" export * from "./decorators" export * from "./event-bus" -export * from "./search" \ No newline at end of file +export * from "./search" +export * from "./modules-sdk" diff --git a/packages/product/src/utils/__tests__/load-database-config.spec.ts b/packages/utils/src/modules-sdk/__tests__/load-database-config.spec.ts similarity index 84% rename from packages/product/src/utils/__tests__/load-database-config.spec.ts rename to packages/utils/src/modules-sdk/__tests__/load-database-config.spec.ts index fffe7f8fec..3ae3584426 100644 --- a/packages/product/src/utils/__tests__/load-database-config.spec.ts +++ b/packages/utils/src/modules-sdk/__tests__/load-database-config.spec.ts @@ -1,4 +1,4 @@ -import { loadDatabaseConfig } from "../load-database-config" +import { loadDatabaseConfig } from "../load-module-database-config" describe("loadDatabaseConfig", function () { afterEach(() => { @@ -8,7 +8,7 @@ describe("loadDatabaseConfig", function () { it("should return the local configuration using the environment variable", function () { process.env.POSTGRES_URL = "postgres://localhost:5432/medusa" - let config = loadDatabaseConfig() + let config = loadDatabaseConfig("product") expect(config).toEqual({ clientUrl: process.env.POSTGRES_URL, @@ -17,12 +17,13 @@ describe("loadDatabaseConfig", function () { ssl: false, }, }, + debug: false, schema: "", }) delete process.env.POSTGRES_URL process.env.PRODUCT_POSTGRES_URL = "postgres://localhost:5432/medusa" - config = loadDatabaseConfig() + config = loadDatabaseConfig("product") expect(config).toEqual({ clientUrl: process.env.PRODUCT_POSTGRES_URL, @@ -31,13 +32,14 @@ describe("loadDatabaseConfig", function () { ssl: false, }, }, + debug: false, schema: "", }) }) it("should return the remote configuration using the environment variable", function () { process.env.POSTGRES_URL = "postgres://https://test.com:5432/medusa" - let config = loadDatabaseConfig() + let config = loadDatabaseConfig("product") expect(config).toEqual({ clientUrl: process.env.POSTGRES_URL, @@ -48,12 +50,13 @@ describe("loadDatabaseConfig", function () { }, }, }, + debug: false, schema: "", }) delete process.env.POSTGRES_URL process.env.PRODUCT_POSTGRES_URL = "postgres://https://test.com:5432/medusa" - config = loadDatabaseConfig() + config = loadDatabaseConfig("product") expect(config).toEqual({ clientUrl: process.env.PRODUCT_POSTGRES_URL, @@ -64,6 +67,7 @@ describe("loadDatabaseConfig", function () { }, }, }, + debug: false, schema: "", }) }) @@ -76,7 +80,7 @@ describe("loadDatabaseConfig", function () { }, } - const config = loadDatabaseConfig(options) + let config = loadDatabaseConfig("product", options) expect(config).toEqual({ clientUrl: options.database.clientUrl, @@ -85,6 +89,7 @@ describe("loadDatabaseConfig", function () { ssl: false, }, }, + debug: false, schema: "", }) }) @@ -97,7 +102,7 @@ describe("loadDatabaseConfig", function () { }, } - const config = loadDatabaseConfig(options) + let config = loadDatabaseConfig("product", options) expect(config).toEqual({ clientUrl: options.database.clientUrl, @@ -108,6 +113,7 @@ describe("loadDatabaseConfig", function () { }, }, }, + debug: false, schema: "", }) }) @@ -115,7 +121,7 @@ describe("loadDatabaseConfig", function () { it("should throw if no clientUrl is provided", function () { let error try { - loadDatabaseConfig() + loadDatabaseConfig("product") } catch (e) { error = e } diff --git a/packages/product/src/utils/query/index.ts b/packages/utils/src/modules-sdk/build-query.ts similarity index 62% rename from packages/product/src/utils/query/index.ts rename to packages/utils/src/modules-sdk/build-query.ts index 2efce91f4b..afadb20cd2 100644 --- a/packages/product/src/utils/query/index.ts +++ b/packages/utils/src/modules-sdk/build-query.ts @@ -1,12 +1,5 @@ -/** - * Move to a new build query utils - */ -import { DAL, FindConfig } from "@medusajs/types" -import { isObject } from "@medusajs/utils" - -export function deduplicateIfNecessary(collection: T | T[]) { - return Array.isArray(collection) ? [...new Set(collection)] : collection -} +import { DAL, FindConfig, SoftDeletableFilterKey } from "@medusajs/types" +import { deduplicate, isObject } from "../common" export function buildQuery( filters: Record = {}, @@ -16,11 +9,18 @@ export function buildQuery( buildWhere(filters, where) const findOptions: DAL.OptionsQuery = { - populate: config.relations ?? [], - fields: config.select, - limit: config.take, + populate: deduplicate(config.relations ?? []), + fields: config.select as string[], + limit: config.take ?? 15, offset: config.skip, - } as any + } + + if (config.withDeleted) { + findOptions.filters ??= {} + findOptions.filters[SoftDeletableFilterKey] = { + withDeleted: true, + } + } return { where, options: findOptions } } @@ -28,7 +28,7 @@ export function buildQuery( function buildWhere(filters: Record = {}, where = {}) { for (let [prop, value] of Object.entries(filters)) { if (Array.isArray(value)) { - value = deduplicateIfNecessary(value) + value = deduplicate(value) where[prop] = ["$in", "$nin"].includes(prop) ? value : { $in: value } continue } diff --git a/packages/utils/src/modules-sdk/decorators/index.ts b/packages/utils/src/modules-sdk/decorators/index.ts new file mode 100644 index 0000000000..ec7b2d1a77 --- /dev/null +++ b/packages/utils/src/modules-sdk/decorators/index.ts @@ -0,0 +1 @@ +export * from "./inject-transaction-manager" diff --git a/packages/utils/src/modules-sdk/decorators/inject-transaction-manager.ts b/packages/utils/src/modules-sdk/decorators/inject-transaction-manager.ts new file mode 100644 index 0000000000..4cb557ea59 --- /dev/null +++ b/packages/utils/src/modules-sdk/decorators/inject-transaction-manager.ts @@ -0,0 +1,48 @@ +import { Context, SharedContext } from "@medusajs/types" + +export function InjectTransactionManager( + shouldForceTransaction: (target: any) => boolean = () => false, + managerProperty?: string +): MethodDecorator { + return function ( + target: any, + propertyKey: string | symbol, + descriptor: any + ): void { + if (!target.MedusaContextIndex_) { + throw new Error( + `To apply @InjectTransactionManager you have to flag a parameter using @MedusaContext` + ) + } + + const originalMethod = descriptor.value + + const argIndex = target.MedusaContextIndex_[propertyKey] + descriptor.value = async function (...args: any[]) { + const shouldForceTransactionRes = shouldForceTransaction(target) + const context: SharedContext | Context = args[argIndex] ?? {} + + if (!shouldForceTransactionRes && context?.transactionManager) { + return await originalMethod.apply(this, args) + } + + return await (!managerProperty + ? this + : this[managerProperty] + ).transaction( + async (transactionManager) => { + args[argIndex] = args[argIndex] ?? {} + args[argIndex].transactionManager = transactionManager + + return await originalMethod.apply(this, args) + }, + { + transaction: context?.transactionManager, + isolationLevel: (context as Context)?.isolationLevel, + enableNestedTransactions: + (context as Context).enableNestedTransactions ?? false, + } + ) + } + } +} diff --git a/packages/utils/src/modules-sdk/index.ts b/packages/utils/src/modules-sdk/index.ts new file mode 100644 index 0000000000..fa21e3b9bf --- /dev/null +++ b/packages/utils/src/modules-sdk/index.ts @@ -0,0 +1,4 @@ +export * from "./load-module-database-config" +export * from "./decorators" +export * from "./build-query" +export * from "./retrieve-entity" diff --git a/packages/product/src/utils/load-database-config.ts b/packages/utils/src/modules-sdk/load-module-database-config.ts similarity index 51% rename from packages/product/src/utils/load-database-config.ts rename to packages/utils/src/modules-sdk/load-module-database-config.ts index 081c868a8f..623b8e41a3 100644 --- a/packages/product/src/utils/load-database-config.ts +++ b/packages/utils/src/modules-sdk/load-module-database-config.ts @@ -1,23 +1,19 @@ -import { - ProductServiceInitializeCustomDataLayerOptions, - ProductServiceInitializeOptions, -} from "../types" -import { MedusaError } from "@medusajs/utils" +import { MedusaError } from "../common" +import { ModulesSdkTypes } from "@medusajs/types" -function getEnv(key: string): string { - const value = process.env[`PRODUCT_${key}`] ?? process.env[`${key}`] +function getEnv(key: string, moduleName: string): string { + const value = + process.env[`${moduleName.toUpperCase()}_${key}`] ?? process.env[`${key}`] return value ?? "" } -function isProductServiceInitializeOptions( +function isModuleServiceInitializeOptions( obj: unknown -): obj is ProductServiceInitializeOptions { - return !!(obj as ProductServiceInitializeOptions)?.database +): obj is ModulesSdkTypes.ModuleServiceInitializeOptions { + return !!(obj as any)?.database } -function getDefaultDriverOptions( - clientUrl: string -): ProductServiceInitializeOptions["database"]["driverOptions"] { +function getDefaultDriverOptions(clientUrl: string) { const localOptions = { connection: { ssl: false, @@ -45,31 +41,35 @@ function getDefaultDriverOptions( /** * Load the config for the database connection. The options can be retrieved - * through PRODUCT_* (e.g PRODUCT_POSTGRES_URL) or * (e.g POSTGRES_URL) environment variables or the options object. + * e.g through PRODUCT_* (e.g PRODUCT_POSTGRES_URL) or * (e.g POSTGRES_URL) environment variables or the options object. * @param options + * @param moduleName */ export function loadDatabaseConfig( + moduleName: string, options?: - | ProductServiceInitializeOptions - | ProductServiceInitializeCustomDataLayerOptions -): ProductServiceInitializeOptions["database"] { - const clientUrl = getEnv("POSTGRES_URL") + | ModulesSdkTypes.ModuleServiceInitializeOptions + | ModulesSdkTypes.ModuleServiceInitializeCustomDataLayerOptions +): ModulesSdkTypes.ModuleServiceInitializeOptions["database"] { + const clientUrl = getEnv("POSTGRES_URL", moduleName) - const database: ProductServiceInitializeOptions["database"] = { - clientUrl: getEnv("POSTGRES_URL"), - schema: getEnv("POSTGRES_SCHEMA") ?? "public", + const database = { + clientUrl: getEnv("POSTGRES_URL", moduleName), + schema: getEnv("POSTGRES_SCHEMA", moduleName) ?? "public", driverOptions: JSON.parse( - getEnv("POSTGRES_DRIVER_OPTIONS") || + getEnv("POSTGRES_DRIVER_OPTIONS", moduleName) || JSON.stringify(getDefaultDriverOptions(clientUrl)) ), + debug: process.env.NODE_ENV?.startsWith("dev") ?? false, } - if (isProductServiceInitializeOptions(options)) { + if (isModuleServiceInitializeOptions(options)) { database.clientUrl = options.database.clientUrl ?? database.clientUrl database.schema = options.database.schema ?? database.schema database.driverOptions = options.database.driverOptions ?? getDefaultDriverOptions(database.clientUrl) + database.debug = options.database.debug ?? database.debug } if (!database.clientUrl) { diff --git a/packages/utils/src/modules-sdk/retrieve-entity.ts b/packages/utils/src/modules-sdk/retrieve-entity.ts new file mode 100644 index 0000000000..dd8f5b8271 --- /dev/null +++ b/packages/utils/src/modules-sdk/retrieve-entity.ts @@ -0,0 +1,47 @@ +import { FindConfig, DAL, Context } from "@medusajs/types" +import { MedusaError, isDefined, lowerCaseFirst } from "../common" +import { buildQuery } from "./build-query" + +type RetrieveEntityParams = { + id: string, + entityName: string, + repository: DAL.TreeRepositoryService + config: FindConfig + sharedContext?: Context +} + +export async function retrieveEntity< + TEntity, + TDTO, +>({ + id, + entityName, + repository, + config = {}, + sharedContext, +}: RetrieveEntityParams): Promise { + if (!isDefined(id)) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `"${lowerCaseFirst(entityName)}Id" must be defined` + ) + } + + const queryOptions = buildQuery({ + id, + }, config) + + const entities = await repository.find( + queryOptions, + sharedContext + ) + + if (!entities?.length) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `${entityName} with id: ${id} was not found` + ) + } + + return entities[0] +} diff --git a/yarn.lock b/yarn.lock index d956fe6a33..b9f8c78b0c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4266,6 +4266,20 @@ __metadata: languageName: node linkType: hard +"@isaacs/cliui@npm:^8.0.2": + version: 8.0.2 + resolution: "@isaacs/cliui@npm:8.0.2" + dependencies: + string-width: ^5.1.2 + string-width-cjs: "npm:string-width@^4.2.0" + strip-ansi: ^7.0.1 + strip-ansi-cjs: "npm:strip-ansi@^6.0.1" + wrap-ansi: ^8.1.0 + wrap-ansi-cjs: "npm:wrap-ansi@^7.0.0" + checksum: b1bf42535d49f11dc137f18d5e4e63a28c5569de438a221c369483731e9dac9fb797af554e8bf02b6192d1e5eba6e6402cf93900c3d0ac86391d00d04876789e + languageName: node + linkType: hard + "@istanbuljs/load-nyc-config@npm:^1.0.0": version: 1.1.0 resolution: "@istanbuljs/load-nyc-config@npm:1.1.0" @@ -6486,6 +6500,7 @@ __metadata: awilix: ^8.0.0 cross-env: ^5.2.1 dotenv: ^16.1.4 + faker: ^6.6.6 jest: ^25.5.4 lodash: ^4.17.21 medusa-test-utils: ^1.1.40 @@ -6542,6 +6557,7 @@ __metadata: express: ^4.18.2 glob: ^7.1.6 jest: ^25.5.4 + rimraf: ^5.0.1 ts-jest: ^25.5.1 typescript: ^4.4.4 ulid: ^2.3.0 @@ -7607,6 +7623,13 @@ __metadata: languageName: node linkType: hard +"@pkgjs/parseargs@npm:^0.11.0": + version: 0.11.0 + resolution: "@pkgjs/parseargs@npm:0.11.0" + checksum: 5bd7576bb1b38a47a7fc7b51ac9f38748e772beebc56200450c4a817d712232b8f1d3ef70532c80840243c657d491cf6a6be1e3a214cff907645819fdc34aadd + languageName: node + linkType: hard + "@pmmmwh/react-refresh-webpack-plugin@npm:^0.4.3": version: 0.4.3 resolution: "@pmmmwh/react-refresh-webpack-plugin@npm:0.4.3" @@ -13995,7 +14018,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^6.0.0": +"ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.1.0": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" checksum: 5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c @@ -21586,6 +21609,13 @@ __metadata: languageName: node linkType: hard +"faker@npm:^6.6.6": + version: 6.6.6 + resolution: "faker@npm:6.6.6" + checksum: 21f6a57adc921ffb61e5ff767024a699a414047264ff4bbb080bd4994ec5a8061e1828aae9a4be812f6f356f1b6fd082bc2d54df7c37163b943a421a8f7f7079 + languageName: node + linkType: hard + "fast-copy@npm:^2.1.0": version: 2.1.3 resolution: "fast-copy@npm:2.1.3" @@ -22208,6 +22238,16 @@ __metadata: languageName: node linkType: hard +"foreground-child@npm:^3.1.0": + version: 3.1.1 + resolution: "foreground-child@npm:3.1.1" + dependencies: + cross-spawn: ^7.0.0 + signal-exit: ^4.0.1 + checksum: 9700a0285628abaeb37007c9a4d92bd49f67210f09067638774338e146c8e9c825c5c877f072b2f75f41dc6a2d0be8664f79ffc03f6576649f54a84fb9b47de0 + languageName: node + linkType: hard + "forever-agent@npm:~0.6.1": version: 0.6.1 resolution: "forever-agent@npm:0.6.1" @@ -23419,6 +23459,21 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.2.5": + version: 10.3.1 + resolution: "glob@npm:10.3.1" + dependencies: + foreground-child: ^3.1.0 + jackspeak: ^2.0.3 + minimatch: ^9.0.1 + minipass: ^5.0.0 || ^6.0.2 + path-scurry: ^1.10.0 + bin: + glob: dist/cjs/src/bin.js + checksum: b39d24c093ce2ffa992dc5b412dbc871af0ccd38a6b2356f67dc906857f0c4c811039a4a4665d19443e1bb484ce2d97855cc7fcfb9a7d0b7e0dadfef4dad5b82 + languageName: node + linkType: hard + "glob@npm:^6.0.1": version: 6.0.4 resolution: "glob@npm:6.0.4" @@ -26060,6 +26115,19 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^2.0.3": + version: 2.2.1 + resolution: "jackspeak@npm:2.2.1" + dependencies: + "@isaacs/cliui": ^8.0.2 + "@pkgjs/parseargs": ^0.11.0 + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 510860a5d1eaf12cba509a09a8f7d1696090bfa7c8ae75c6d9c836890d2897409f3b3dd91039cf0020627d6eba8c024f571ae4d78bd956162b07794ddfb9dd62 + languageName: node + linkType: hard + "jest-changed-files@npm:^25.5.0": version: 25.5.0 resolution: "jest-changed-files@npm:25.5.0" @@ -29919,6 +29987,13 @@ __metadata: languageName: node linkType: hard +"lru-cache@npm:^9.1.1 || ^10.0.0": + version: 10.0.0 + resolution: "lru-cache@npm:10.0.0" + checksum: 347b7b391091e9f91182b6f683ce04329932a542376a2d7d300637213b99f06c222a3bb0f0db59adf246dac6cef1bb509cab352451a96621d07c41b10a20495f + languageName: node + linkType: hard + "lru-queue@npm:^0.1.0": version: 0.1.0 resolution: "lru-queue@npm:0.1.0" @@ -31519,6 +31594,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^9.0.1": + version: 9.0.2 + resolution: "minimatch@npm:9.0.2" + dependencies: + brace-expansion: ^2.0.1 + checksum: 39157d5fd831a7981f7c0c5b22a0e0c2ae8a987ec4a4aeaacc21d3e85da24ce812808cbf7c07cde0d63ad1cf307f73be581131a7a84eeda65f00be1f51972471 + languageName: node + linkType: hard + "minimist-options@npm:^4.0.2": version: 4.1.0 resolution: "minimist-options@npm:4.1.0" @@ -31614,6 +31698,13 @@ __metadata: languageName: node linkType: hard +"minipass@npm:^5.0.0 || ^6.0.2": + version: 6.0.2 + resolution: "minipass@npm:6.0.2" + checksum: 3878076578f44ef4078ceed10af2cfebbec1b6217bf9f7a3d8b940da8153769db29bf88498b2de0d1e0c12dfb7b634c5729b7ca03457f46435e801578add210a + languageName: node + linkType: hard + "minizlib@npm:^1.3.3": version: 1.3.3 resolution: "minizlib@npm:1.3.3" @@ -33913,6 +34004,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^1.10.0": + version: 1.10.0 + resolution: "path-scurry@npm:1.10.0" + dependencies: + lru-cache: ^9.1.1 || ^10.0.0 + minipass: ^5.0.0 || ^6.0.2 + checksum: dcc4109928c9a0991f0e1719c73b0a184eb7f313fe3eb2242f25274b1e761f53041989fb6b069541b88f58ee6dfbbecf94922225e0c5a3fba8112c9c60abb391 + languageName: node + linkType: hard + "path-to-regexp@npm:0.1.7": version: 0.1.7 resolution: "path-to-regexp@npm:0.1.7" @@ -37479,6 +37580,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:^5.0.1": + version: 5.0.1 + resolution: "rimraf@npm:5.0.1" + dependencies: + glob: ^10.2.5 + bin: + rimraf: dist/cjs/src/bin.js + checksum: 9e6062c0aea96f384dd937e6bb06b624c881de2eee79a83d3068193183d44eb9b1f3f68a27a54b9ca8cce56bf34c2951ff4239b093b970e0501a091907031f52 + languageName: node + linkType: hard + "rimraf@npm:~2.4.0": version: 2.4.5 resolution: "rimraf@npm:2.4.5" @@ -38414,6 +38526,13 @@ __metadata: languageName: node linkType: hard +"signal-exit@npm:^4.0.1": + version: 4.0.2 + resolution: "signal-exit@npm:4.0.2" + checksum: 3c36ae214f4774b4a7cbbd2d090b2864f8da4dc3f9140ba5b76f38bea7605c7aa8042adf86e48ee8a0955108421873f9b0f20281c61b8a65da4d9c1c1de4929f + languageName: node + linkType: hard + "signedsource@npm:^1.0.0": version: 1.0.0 resolution: "signedsource@npm:1.0.0" @@ -39280,7 +39399,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.2, string-width@npm:^4.2.3": +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^1.0.2 || 2 || 3 || 4, string-width@npm:^4.0.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.2, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -39302,7 +39421,7 @@ __metadata: languageName: node linkType: hard -"string-width@npm:^5.0.0": +"string-width@npm:^5.0.0, string-width@npm:^5.0.1, string-width@npm:^5.1.2": version: 5.1.2 resolution: "string-width@npm:5.1.2" dependencies: @@ -39447,6 +39566,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: ^5.0.1 + checksum: 1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 + languageName: node + linkType: hard + "strip-ansi@npm:^3.0.0, strip-ansi@npm:^3.0.1": version: 3.0.1 resolution: "strip-ansi@npm:3.0.1" @@ -39465,15 +39593,6 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": - version: 6.0.1 - resolution: "strip-ansi@npm:6.0.1" - dependencies: - ansi-regex: ^5.0.1 - checksum: 1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 - languageName: node - linkType: hard - "strip-ansi@npm:^7.0.1": version: 7.0.1 resolution: "strip-ansi@npm:7.0.1" @@ -43203,6 +43322,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": + version: 7.0.0 + resolution: "wrap-ansi@npm:7.0.0" + dependencies: + ansi-styles: ^4.0.0 + string-width: ^4.1.0 + strip-ansi: ^6.0.0 + checksum: d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da + languageName: node + linkType: hard + "wrap-ansi@npm:^6.0.1, wrap-ansi@npm:^6.2.0": version: 6.2.0 resolution: "wrap-ansi@npm:6.2.0" @@ -43214,14 +43344,14 @@ __metadata: languageName: node linkType: hard -"wrap-ansi@npm:^7.0.0": - version: 7.0.0 - resolution: "wrap-ansi@npm:7.0.0" +"wrap-ansi@npm:^8.1.0": + version: 8.1.0 + resolution: "wrap-ansi@npm:8.1.0" dependencies: - ansi-styles: ^4.0.0 - string-width: ^4.1.0 - strip-ansi: ^6.0.0 - checksum: d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da + ansi-styles: ^6.1.0 + string-width: ^5.0.1 + strip-ansi: ^7.0.1 + checksum: 138ff58a41d2f877eae87e3282c0630fc2789012fc1af4d6bd626eeb9a2f9a65ca92005e6e69a75c7b85a68479fe7443c7dbe1eb8fbaa681a4491364b7c55c60 languageName: node linkType: hard