From 281b0746cfbe80b83c6a67d1ea120b47a0ea7121 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 8 Aug 2023 12:10:27 +0200 Subject: [PATCH] feat(medusa,workflows) Create cart workflow (#4685) * chore: add baseline test for create cart * chore: add basic paths into handlers + make first tests pass * chore: move input alias to cart specific workflow * chore: move data around into buckets * chore: normalize handlers and introduce types * chore: move aliases to handlers concern * chore: add compensation step for create cart * chore: merge with latest develop * chore: handle error manually + type inputs * chore: handle error manually * chore: added types for each handler * chore: remove addresses * chore: added changset * chore: undo package changes * chore: added config settings to retreieve, cleanup of types * chore: capitalize cart handlers * chore: rename todo * chore: add feature flag for workflow * chore: reorder handlers * chore: add logger to route handler * chore: removed weird vscode moving around things * chore: refactor handlers * chore: refactor compensate step * chore: changed poistion * chore: aggregate config data * chore: moved handlers to their own domain + pr review addressing * chore: address pr reviews * chore: move types to type package * chore: update type to include config * chore: remove error scoping --- .changeset/shy-kiwis-jog.md | 7 + .../plugins/__tests__/cart/store/index.ts | 210 ++++++++++++++++ integration-tests/plugins/medusa-config.js | 1 + .../src/api/routes/store/carts/create-cart.ts | 50 +++- packages/types/src/address/common.ts | 15 ++ packages/types/src/address/index.ts | 1 + packages/types/src/cart/common.ts | 27 ++ packages/types/src/cart/index.ts | 1 + packages/types/src/index.ts | 10 +- .../types/src/workflow/cart/create-cart.ts | 22 ++ packages/types/src/workflow/cart/index.ts | 1 + packages/types/src/workflow/index.ts | 3 +- .../src/workflow/product/create-products.ts | 4 - .../src/definition/cart/create-cart.ts | 238 ++++++++++++++++++ .../workflows/src/definition/cart/index.ts | 1 + packages/workflows/src/definition/index.ts | 1 + packages/workflows/src/definitions.ts | 4 + .../address/find-or-create-addresses.ts | 112 +++++++++ .../workflows/src/handlers/address/index.ts | 1 + .../cart/attach-line-items-to-cart.ts | 57 +++++ .../src/handlers/cart/create-cart.ts | 57 +++++ packages/workflows/src/handlers/cart/index.ts | 4 + .../src/handlers/cart/remove-cart.ts | 28 +++ .../src/handlers/cart/retrieve-cart.ts | 40 +++ .../workflows/src/handlers/common/index.ts | 2 + .../src/handlers/common/set-config.ts | 31 +++ .../src/handlers/common/set-context.ts | 27 ++ .../customer/find-or-create-customer.ts | 63 +++++ .../workflows/src/handlers/customer/index.ts | 1 + packages/workflows/src/handlers/index.ts | 8 +- .../src/handlers/region/find-region.ts | 49 ++++ .../workflows/src/handlers/region/index.ts | 1 + .../sales-channel/find-sales-channel.ts | 74 ++++++ .../src/handlers/sales-channel/index.ts | 1 + packages/workflows/src/helper/index.ts | 1 + 35 files changed, 1140 insertions(+), 13 deletions(-) create mode 100644 .changeset/shy-kiwis-jog.md create mode 100644 integration-tests/plugins/__tests__/cart/store/index.ts create mode 100644 packages/types/src/address/common.ts create mode 100644 packages/types/src/address/index.ts create mode 100644 packages/types/src/cart/common.ts create mode 100644 packages/types/src/cart/index.ts create mode 100644 packages/types/src/workflow/cart/create-cart.ts create mode 100644 packages/types/src/workflow/cart/index.ts create mode 100644 packages/workflows/src/definition/cart/create-cart.ts create mode 100644 packages/workflows/src/definition/cart/index.ts create mode 100644 packages/workflows/src/handlers/address/find-or-create-addresses.ts create mode 100644 packages/workflows/src/handlers/address/index.ts create mode 100644 packages/workflows/src/handlers/cart/attach-line-items-to-cart.ts create mode 100644 packages/workflows/src/handlers/cart/create-cart.ts create mode 100644 packages/workflows/src/handlers/cart/index.ts create mode 100644 packages/workflows/src/handlers/cart/remove-cart.ts create mode 100644 packages/workflows/src/handlers/cart/retrieve-cart.ts create mode 100644 packages/workflows/src/handlers/common/index.ts create mode 100644 packages/workflows/src/handlers/common/set-config.ts create mode 100644 packages/workflows/src/handlers/common/set-context.ts create mode 100644 packages/workflows/src/handlers/customer/find-or-create-customer.ts create mode 100644 packages/workflows/src/handlers/customer/index.ts create mode 100644 packages/workflows/src/handlers/region/find-region.ts create mode 100644 packages/workflows/src/handlers/region/index.ts create mode 100644 packages/workflows/src/handlers/sales-channel/find-sales-channel.ts create mode 100644 packages/workflows/src/handlers/sales-channel/index.ts diff --git a/.changeset/shy-kiwis-jog.md b/.changeset/shy-kiwis-jog.md new file mode 100644 index 0000000000..3b5a8bc34c --- /dev/null +++ b/.changeset/shy-kiwis-jog.md @@ -0,0 +1,7 @@ +--- +"@medusajs/workflows": patch +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(medusa,workflows,types) Create cart workflow diff --git a/integration-tests/plugins/__tests__/cart/store/index.ts b/integration-tests/plugins/__tests__/cart/store/index.ts new file mode 100644 index 0000000000..164209820f --- /dev/null +++ b/integration-tests/plugins/__tests__/cart/store/index.ts @@ -0,0 +1,210 @@ +import { MoneyAmount, PriceList, Region } from "@medusajs/medusa" +import path from "path" + +import { bootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import setupServer from "../../../../environment-helpers/setup-server" +import { setPort, useApi } from "../../../../environment-helpers/use-api" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import { simpleProductFactory } from "../../../../factories" + +jest.setTimeout(30000) + +describe("/store/carts", () => { + let medusaProcess + let dbConnection + let express + + const doAfterEach = async () => { + const db = useDb() + return await db.teardown() + } + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd }) + medusaProcess = await setupServer({ cwd, verbose: true }) + const { app, port } = await bootstrapApp({ cwd }) + setPort(port) + express = app.listen(port, () => { + process.send?.(port) + }) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + medusaProcess.kill() + }) + + describe("POST /store/carts", () => { + let prod1 + let prodSale + + beforeEach(async () => { + const manager = dbConnection.manager + await manager.insert(Region, { + id: "region", + name: "Test Region", + currency_code: "usd", + tax_rate: 0, + }) + + await manager.query( + `UPDATE "country" + SET region_id='region' + WHERE iso_2 = 'us'` + ) + + prod1 = await simpleProductFactory(dbConnection, { + id: "test-product", + variants: [{ id: "test-variant_1" }], + }) + + prodSale = await simpleProductFactory(dbConnection, { + id: "test-product-sale", + variants: [ + { + id: "test-variant-sale", + prices: [{ amount: 1000, currency: "usd" }], + }, + ], + }) + }) + + afterEach(async () => { + await doAfterEach() + }) + + it("should create a cart", async () => { + const api = useApi() + const response = await api.post("/store/carts") + + expect(response.status).toEqual(200) + + const getRes = await api.post(`/store/carts/${response.data.cart.id}`) + expect(getRes.status).toEqual(200) + }) + + it("should fail to create a cart when no region exist", async () => { + const api = useApi() + + await dbConnection.manager.query( + `UPDATE "country" + SET region_id=null + WHERE iso_2 = 'us'` + ) + + await dbConnection.manager.query(`DELETE from region`) + + try { + await api.post("/store/carts") + } catch (error) { + expect(error.response.status).toEqual(400) + expect(error.response.data.message).toEqual( + "A region is required to create a cart" + ) + } + }) + + it("should create a cart with items", async () => { + const yesterday = ((today) => + new Date(today.setDate(today.getDate() - 1)))(new Date()) + const tomorrow = ((today) => + new Date(today.setDate(today.getDate() + 1)))(new Date()) + + const priceList1 = await dbConnection.manager.create(PriceList, { + id: "pl_current", + name: "Past winter sale", + description: "Winter sale for key accounts.", + type: "sale", + status: "active", + starts_at: yesterday, + ends_at: tomorrow, + }) + + await dbConnection.manager.save(priceList1) + + const ma_sale_1 = dbConnection.manager.create(MoneyAmount, { + variant_id: prodSale.variants[0].id, + currency_code: "usd", + amount: 800, + price_list_id: "pl_current", + }) + + await dbConnection.manager.save(ma_sale_1) + + const api = useApi() + + const response = await api + .post("/store/carts", { + items: [ + { + variant_id: prod1.variants[0].id, + quantity: 1, + }, + { + variant_id: prodSale.variants[0].id, + quantity: 2, + }, + ], + }) + .catch((err) => console.log(err)) + + response.data.cart.items.sort((a, b) => a.quantity - b.quantity) + + expect(response.status).toEqual(200) + expect(response.data.cart.items).toHaveLength(2) + expect(response.data.cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + variant_id: prod1.variants[0].id, + quantity: 1, + }), + expect.objectContaining({ + variant_id: prodSale.variants[0].id, + quantity: 2, + unit_price: 800, + }), + ]) + ) + + const getRes = await api.post(`/store/carts/${response.data.cart.id}`) + expect(getRes.status).toEqual(200) + }) + + it("should create a cart with country", async () => { + const api = useApi() + const response = await api.post("/store/carts", { + country_code: "us", + }) + + expect(response.status).toEqual(200) + expect(response.data.cart.shipping_address.country_code).toEqual("us") + + const getRes = await api.post(`/store/carts/${response.data.cart.id}`) + expect(getRes.status).toEqual(200) + }) + + it("should create a cart with context", async () => { + const api = useApi() + + const response = await api.post("/store/carts", { + context: { + test_id: "test", + }, + }) + + expect(response.status).toEqual(200) + + const getRes = await api.post(`/store/carts/${response.data.cart.id}`) + expect(getRes.status).toEqual(200) + + const cart = getRes.data.cart + expect(cart.context).toEqual({ + ip: expect.any(String), + user_agent: expect.stringContaining("axios/0.21."), + test_id: "test", + }) + }) + }) +}) diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index 1ea26e8f81..915999cf10 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -35,6 +35,7 @@ module.exports = { featureFlags: { workflows: { [Workflows.CreateProducts]: true, + [Workflows.CreateCart]: true, }, }, modules: { diff --git a/packages/medusa/src/api/routes/store/carts/create-cart.ts b/packages/medusa/src/api/routes/store/carts/create-cart.ts index 2457ba1cf5..443b682913 100644 --- a/packages/medusa/src/api/routes/store/carts/create-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/create-cart.ts @@ -1,3 +1,8 @@ +import { MedusaContainer } from "@medusajs/modules-sdk" +import { + Workflows, + createCart as createCartWorkflow, +} from "@medusajs/workflows" import { Type } from "class-transformer" import { IsArray, @@ -7,10 +12,11 @@ import { IsString, ValidateNested, } from "class-validator" -import { isDefined, MedusaError } from "medusa-core-utils" +import { MedusaError, isDefined } from "medusa-core-utils" import reqIp from "request-ip" import { EntityManager } from "typeorm" +import { Logger } from "@medusajs/types" import { defaultStoreCartFields, defaultStoreCartRelations } from "." import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" import { Cart, LineItem } from "../../../../models" @@ -75,18 +81,56 @@ import { FlagRouter } from "../../../../utils/flag-router" * $ref: "#/components/responses/500_error" */ export default async (req, res) => { + const entityManager: EntityManager = req.scope.resolve("manager") + const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter") const validated = req.validatedBody as StorePostCartReq + const logger: Logger = req.scope.resolve("logger") const reqContext = { ip: reqIp.getClientIp(req), user_agent: req.get("user-agent"), } + const isWorkflowEnabled = featureFlagRouter.isFeatureEnabled({ + workflows: Workflows.CreateCart, + }) + + if (isWorkflowEnabled) { + const cartWorkflow = createCartWorkflow(req.scope as MedusaContainer) + const input = { + ...validated, + publishableApiKeyScopes: req.publishableApiKeyScopes, + context: { + ...reqContext, + ...validated.context, + }, + config: { + retrieveConfig: { + select: defaultStoreCartFields, + relations: defaultStoreCartRelations, + }, + }, + } + const { result, errors } = await cartWorkflow.run({ + input, + context: { + manager: entityManager, + }, + throwOnError: false, + }) + + if (Array.isArray(errors)) { + if (isDefined(errors[0])) { + throw errors[0].error + } + } + + return res.status(200).json({ cart: cleanResponseData(result, []) }) + } + const lineItemService: LineItemService = req.scope.resolve("lineItemService") const cartService: CartService = req.scope.resolve("cartService") const regionService: RegionService = req.scope.resolve("regionService") - const entityManager: EntityManager = req.scope.resolve("manager") - const featureFlagRouter: FlagRouter = req.scope.resolve("featureFlagRouter") let regionId!: string if (isDefined(validated.region_id)) { diff --git a/packages/types/src/address/common.ts b/packages/types/src/address/common.ts new file mode 100644 index 0000000000..2d1fc8c804 --- /dev/null +++ b/packages/types/src/address/common.ts @@ -0,0 +1,15 @@ +export type AddressDTO = { + id?: string + address_1: string + address_2?: string | null + company?: string | null + country_code: string + city?: string | null + phone?: string | null + postal_code?: string | null + province?: string | null + metadata?: Record | null + created_at?: string | Date + updated_at?: string | Date + deleted_at?: string | Date | null +} diff --git a/packages/types/src/address/index.ts b/packages/types/src/address/index.ts new file mode 100644 index 0000000000..488a94fdff --- /dev/null +++ b/packages/types/src/address/index.ts @@ -0,0 +1 @@ +export * from "./common" diff --git a/packages/types/src/cart/common.ts b/packages/types/src/cart/common.ts new file mode 100644 index 0000000000..06826df964 --- /dev/null +++ b/packages/types/src/cart/common.ts @@ -0,0 +1,27 @@ +export type CartDTO = { + id?: string + email?: string + billing_address_id?: string + shipping_address_id?: string + region_id?: string + customer_id?: string + payment_id?: string + completed_at?: Date + payment_authorized_at?: Date + idempotency_key?: string + context?: Record + metadata?: Record + sales_channel_id?: string | null + shipping_total?: number + discount_total?: number + raw_discount_total?: number + item_tax_total?: number | null + shipping_tax_total?: number | null + tax_total?: number | null + refunded_total?: number + total?: number + subtotal?: number + refundable_amount?: number + gift_card_total?: number + gift_card_tax_total?: number +} diff --git a/packages/types/src/cart/index.ts b/packages/types/src/cart/index.ts new file mode 100644 index 0000000000..488a94fdff --- /dev/null +++ b/packages/types/src/cart/index.ts @@ -0,0 +1 @@ +export * from "./common" diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 86d8dbe658..3247ffaa9d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,18 +1,20 @@ +export * from "./address" export * from "./bundles" export * from "./cache" +export * from "./cart" export * from "./common" +export * from "./dal" export * from "./event-bus" +export * from "./feature-flag" export * from "./inventory" export * from "./joiner" +export * from "./logger" export * from "./modules-sdk" export * from "./product" export * from "./product-category" +export * from "./sales-channel" export * from "./search" export * from "./shared-context" export * from "./stock-location" export * from "./transaction-base" -export * from "./dal" -export * from "./logger" -export * from "./feature-flag" -export * from "./sales-channel" export * from "./workflow" diff --git a/packages/types/src/workflow/cart/create-cart.ts b/packages/types/src/workflow/cart/create-cart.ts new file mode 100644 index 0000000000..ecdd322b8f --- /dev/null +++ b/packages/types/src/workflow/cart/create-cart.ts @@ -0,0 +1,22 @@ +import { AddressDTO } from "../../address" +import { WorkflowInputConfig } from "../common" + +export interface CreateLineItemInputDTO { + variant_id: string + quantity: number +} + +export interface CreateCartWorkflowInputDTO { + config?: WorkflowInputConfig + region_id?: string + country_code?: string + items?: CreateLineItemInputDTO[] + context?: object + sales_channel_id?: string + shipping_address_id?: string + billing_address_id?: string + billing_address?: AddressDTO + shipping_address?: AddressDTO + customer_id?: string + email?: string +} diff --git a/packages/types/src/workflow/cart/index.ts b/packages/types/src/workflow/cart/index.ts new file mode 100644 index 0000000000..557e81b94e --- /dev/null +++ b/packages/types/src/workflow/cart/index.ts @@ -0,0 +1 @@ +export * from "./create-cart" diff --git a/packages/types/src/workflow/index.ts b/packages/types/src/workflow/index.ts index ee0394daae..8294d17488 100644 --- a/packages/types/src/workflow/index.ts +++ b/packages/types/src/workflow/index.ts @@ -1,2 +1,3 @@ -export * as ProductWorkflow from "./product" +export * as CartWorkflow from "./cart" export * as CommonWorkflow from "./common" +export * as ProductWorkflow from "./product" diff --git a/packages/types/src/workflow/product/create-products.ts b/packages/types/src/workflow/product/create-products.ts index e587e4c36e..7b5544d331 100644 --- a/packages/types/src/workflow/product/create-products.ts +++ b/packages/types/src/workflow/product/create-products.ts @@ -88,10 +88,6 @@ export interface CreateProductInputDTO { metadata?: Record sales_channels?: CreateProductSalesChannelInputDTO[] - listConfig: { - select: string[] - relations: string[] - } } export interface CreateProductsWorkflowInputDTO { diff --git a/packages/workflows/src/definition/cart/create-cart.ts b/packages/workflows/src/definition/cart/create-cart.ts new file mode 100644 index 0000000000..8c09ecc67f --- /dev/null +++ b/packages/workflows/src/definition/cart/create-cart.ts @@ -0,0 +1,238 @@ +import { + TransactionStepsDefinition, + WorkflowManager, +} from "@medusajs/orchestration" +import { CartWorkflow } from "@medusajs/types" + +import { Workflows } from "../../definitions" +import { + AddressHandlers, + CartHandlers, + CommonHandlers, + CustomerHandlers, + RegionHandlers, + SalesChannelHandlers, +} from "../../handlers" +import { aggregateData, exportWorkflow, pipe } from "../../helper" + +enum CreateCartActions { + setConfig = "setConfig", + setContext = "setContext", + attachLineItems = "attachLineItems", + findRegion = "findRegion", + findSalesChannel = "findSalesChannel", + createCart = "createCart", + findOrCreateAddresses = "findOrCreateAddresses", + findOrCreateCustomer = "findOrCreateCustomer", + removeCart = "removeCart", + removeAddresses = "removeAddresses", + retrieveCart = "retrieveCart", +} + +const workflowAlias = "cart" +const getWorkflowInput = (alias = workflowAlias) => ({ + inputAlias: workflowAlias, + invoke: { + from: workflowAlias, + alias, + }, +}) + +const workflowSteps: TransactionStepsDefinition = { + next: [ + { + action: CreateCartActions.setConfig, + noCompensation: true, + }, + { + action: CreateCartActions.findOrCreateCustomer, + noCompensation: true, + }, + { + action: CreateCartActions.findSalesChannel, + noCompensation: true, + }, + { + action: CreateCartActions.setContext, + noCompensation: true, + }, + { + action: CreateCartActions.findRegion, + noCompensation: true, + next: { + action: CreateCartActions.findOrCreateAddresses, + noCompensation: true, + next: { + action: CreateCartActions.createCart, + next: { + action: CreateCartActions.attachLineItems, + noCompensation: true, + next: { + action: CreateCartActions.retrieveCart, + noCompensation: true, + }, + }, + }, + }, + }, + ], +} + +const handlers = new Map([ + [ + CreateCartActions.setConfig, + { + invoke: pipe( + getWorkflowInput(CommonHandlers.setConfig.aliases.Config), + aggregateData(), + CommonHandlers.setConfig + ), + }, + ], + [ + CreateCartActions.findOrCreateCustomer, + { + invoke: pipe( + getWorkflowInput( + CustomerHandlers.findOrCreateCustomer.aliases.Customer + ), + CustomerHandlers.findOrCreateCustomer + ), + }, + ], + [ + CreateCartActions.findSalesChannel, + { + invoke: pipe( + getWorkflowInput( + SalesChannelHandlers.findSalesChannel.aliases.SalesChannel + ), + SalesChannelHandlers.findSalesChannel + ), + }, + ], + [ + CreateCartActions.setContext, + { + invoke: pipe( + getWorkflowInput(CommonHandlers.setContext.aliases.Context), + CommonHandlers.setContext + ), + }, + ], + [ + CreateCartActions.findRegion, + { + invoke: pipe( + getWorkflowInput(RegionHandlers.findRegion.aliases.Region), + RegionHandlers.findRegion + ), + }, + ], + [ + CreateCartActions.findOrCreateAddresses, + { + invoke: pipe( + { + invoke: [ + getWorkflowInput( + AddressHandlers.findOrCreateAddresses.aliases.Addresses + ).invoke, + { + from: CreateCartActions.findRegion, + alias: AddressHandlers.findOrCreateAddresses.aliases.Region, + }, + ], + }, + AddressHandlers.findOrCreateAddresses + ), + }, + ], + [ + CreateCartActions.createCart, + { + invoke: pipe( + { + invoke: [ + { + from: CreateCartActions.findRegion, + alias: CartHandlers.createCart.aliases.Region, + }, + { + from: CreateCartActions.setContext, + alias: CartHandlers.createCart.aliases.Context, + }, + { + from: CreateCartActions.findOrCreateCustomer, + alias: CartHandlers.createCart.aliases.Customer, + }, + { + from: CreateCartActions.findOrCreateAddresses, + alias: CartHandlers.createCart.aliases.Addresses, + }, + ], + }, + CartHandlers.createCart + ), + compensate: pipe( + { + invoke: [ + { + from: CreateCartActions.createCart, + alias: CartHandlers.removeCart.aliases.Cart, + }, + ], + }, + CartHandlers.removeCart + ), + }, + ], + [ + CreateCartActions.attachLineItems, + { + invoke: pipe( + { + invoke: [ + getWorkflowInput( + CartHandlers.attachLineItemsToCart.aliases.LineItems + ).invoke, + { + from: CreateCartActions.createCart, + alias: CartHandlers.attachLineItemsToCart.aliases.Cart, + }, + ], + }, + CartHandlers.attachLineItemsToCart + ), + }, + ], + [ + CreateCartActions.retrieveCart, + { + invoke: pipe( + { + invoke: [ + { + from: CreateCartActions.setConfig, + alias: CommonHandlers.setConfig.aliases.Config, + }, + { + from: CreateCartActions.createCart, + alias: CartHandlers.retrieveCart.aliases.Cart, + }, + ], + }, + CartHandlers.retrieveCart + ), + }, + ], +]) + +WorkflowManager.register(Workflows.CreateCart, workflowSteps, handlers) + +type CreateCartWorkflowOutput = Record + +export const createCart = exportWorkflow< + CartWorkflow.CreateCartWorkflowInputDTO, + CreateCartWorkflowOutput +>(Workflows.CreateCart, CreateCartActions.retrieveCart) diff --git a/packages/workflows/src/definition/cart/index.ts b/packages/workflows/src/definition/cart/index.ts new file mode 100644 index 0000000000..557e81b94e --- /dev/null +++ b/packages/workflows/src/definition/cart/index.ts @@ -0,0 +1 @@ +export * from "./create-cart" diff --git a/packages/workflows/src/definition/index.ts b/packages/workflows/src/definition/index.ts index fa30720e57..7c7111dac8 100644 --- a/packages/workflows/src/definition/index.ts +++ b/packages/workflows/src/definition/index.ts @@ -1 +1,2 @@ export * from "./create-products" +export * from "./cart" diff --git a/packages/workflows/src/definitions.ts b/packages/workflows/src/definitions.ts index 08b3348a61..c41de3610e 100644 --- a/packages/workflows/src/definitions.ts +++ b/packages/workflows/src/definitions.ts @@ -1,5 +1,9 @@ export enum Workflows { + // Product workflows CreateProducts = "create-products", + + // Cart workflows + CreateCart = "create-cart", } export enum InputAlias { diff --git a/packages/workflows/src/handlers/address/find-or-create-addresses.ts b/packages/workflows/src/handlers/address/find-or-create-addresses.ts new file mode 100644 index 0000000000..3f8f330410 --- /dev/null +++ b/packages/workflows/src/handlers/address/find-or-create-addresses.ts @@ -0,0 +1,112 @@ +import { AddressDTO } from "@medusajs/types" +import { MedusaError } from "@medusajs/utils" + +import { WorkflowArguments } from "../../helper" + +type AddressesDTO = { + shipping_address_id?: string + billing_address_id?: string +} + +type HandlerInputData = { + addresses: AddressesDTO & { + billing_address?: AddressDTO + shipping_address?: AddressDTO + } + region: { + region_id?: string + } +} + +enum Aliases { + Addresses = "addresses", + Region = "region", +} + +export async function findOrCreateAddresses({ + container, + data, +}: WorkflowArguments): Promise { + const regionService = container.resolve("regionService") + const addressRepository = container.resolve("addressRepository") + + const shippingAddress = data[Aliases.Addresses].shipping_address + const shippingAddressId = data[Aliases.Addresses].shipping_address_id + const billingAddress = data[Aliases.Addresses].billing_address + const billingAddressId = data[Aliases.Addresses].billing_address_id + const addressesDTO: AddressesDTO = {} + + const region = await regionService.retrieve(data[Aliases.Region].region_id, { + relations: ["countries"], + }) + + const regionCountries = region.countries.map(({ iso_2 }) => iso_2) + + if (!shippingAddress && !shippingAddressId) { + if (region.countries.length === 1) { + const shippingAddress = addressRepository.create({ + country_code: regionCountries[0], + }) + + addressesDTO.shipping_address_id = shippingAddress?.id + } + } else { + if (shippingAddress) { + if (!regionCountries.includes(shippingAddress.country_code!)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Shipping country not in region" + ) + } + } + + if (shippingAddressId) { + const address = await regionService.findOne({ + where: { id: shippingAddressId }, + }) + + if ( + address?.country_code && + !regionCountries.includes(address.country_code) + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Shipping country not in region" + ) + } + + addressesDTO.shipping_address_id = address.id + } + } + + if (billingAddress) { + if (!regionCountries.includes(billingAddress.country_code!)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Billing country not in region" + ) + } + } + + if (billingAddressId) { + const address = await regionService.findOne({ + where: { id: billingAddressId }, + }) + + if ( + address?.country_code && + !regionCountries.includes(address.country_code) + ) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Billing country not in region" + ) + } + + addressesDTO.billing_address_id = billingAddressId + } + + return addressesDTO +} + +findOrCreateAddresses.aliases = Aliases diff --git a/packages/workflows/src/handlers/address/index.ts b/packages/workflows/src/handlers/address/index.ts new file mode 100644 index 0000000000..0d9a30ea6f --- /dev/null +++ b/packages/workflows/src/handlers/address/index.ts @@ -0,0 +1 @@ +export * from "./find-or-create-addresses" diff --git a/packages/workflows/src/handlers/cart/attach-line-items-to-cart.ts b/packages/workflows/src/handlers/cart/attach-line-items-to-cart.ts new file mode 100644 index 0000000000..df3ea90cf9 --- /dev/null +++ b/packages/workflows/src/handlers/cart/attach-line-items-to-cart.ts @@ -0,0 +1,57 @@ +import { CartWorkflow } from "@medusajs/types" +import { SalesChannelFeatureFlag } from "@medusajs/utils" + +import { WorkflowArguments } from "../../helper" + +type HandlerInputData = { + line_items: { + items?: CartWorkflow.CreateLineItemInputDTO[] + } + cart: { + id: string + customer_id: string + region_id: string + } +} + +enum Aliases { + LineItems = "line_items", + Cart = "cart", +} + +export async function attachLineItemsToCart({ + container, + context, + data, +}: WorkflowArguments): Promise { + const { manager } = context + + const featureFlagRouter = container.resolve("featureFlagRouter") + const lineItemService = container.resolve("lineItemService") + const cartService = container.resolve("cartService") + + const lineItemServiceTx = lineItemService.withTransaction(manager) + const cartServiceTx = cartService.withTransaction(manager) + let lineItems = data[Aliases.LineItems].items + const cart = data[Aliases.Cart] + + if (lineItems?.length) { + const generateInputData = lineItems.map((item) => ({ + variantId: item.variant_id, + quantity: item.quantity, + })) + + lineItems = await lineItemServiceTx.generate(generateInputData, { + region_id: cart.region_id, + customer_id: cart.customer_id, + }) + + await cartServiceTx.addOrUpdateLineItems(cart.id, lineItems, { + validateSalesChannels: featureFlagRouter.isFeatureEnabled( + SalesChannelFeatureFlag.key + ), + }) + } +} + +attachLineItemsToCart.aliases = Aliases diff --git a/packages/workflows/src/handlers/cart/create-cart.ts b/packages/workflows/src/handlers/cart/create-cart.ts new file mode 100644 index 0000000000..ab82579ae4 --- /dev/null +++ b/packages/workflows/src/handlers/cart/create-cart.ts @@ -0,0 +1,57 @@ +import { CartDTO } from "@medusajs/types" +import { WorkflowArguments } from "../../helper" + +enum Aliases { + SalesChannel = "SalesChannel", + Addresses = "addresses", + Customer = "customer", + Region = "region", + Context = "context", +} + +type HandlerInputData = { + sales_channel: { + sales_channel_id?: string + } + addresses: { + shipping_address_id: string + billing_address_id: string + } + customer: { + customer_id?: string + email?: string + } + region: { + region_id: string + } + context: { + context: Record + } +} + +type HandlerOutputData = { + cart: CartDTO +} + +export async function createCart({ + container, + context, + data, +}: WorkflowArguments): Promise { + const { manager } = context + + const cartService = container.resolve("cartService") + const cartServiceTx = cartService.withTransaction(manager) + + const cart = await cartServiceTx.create({ + ...data[Aliases.SalesChannel], + ...data[Aliases.Addresses], + ...data[Aliases.Customer], + ...data[Aliases.Region], + ...data[Aliases.Context], + }) + + return cart +} + +createCart.aliases = Aliases diff --git a/packages/workflows/src/handlers/cart/index.ts b/packages/workflows/src/handlers/cart/index.ts new file mode 100644 index 0000000000..5c2f81eaa2 --- /dev/null +++ b/packages/workflows/src/handlers/cart/index.ts @@ -0,0 +1,4 @@ +export * from "./attach-line-items-to-cart" +export * from "./create-cart" +export * from "./remove-cart" +export * from "./retrieve-cart" diff --git a/packages/workflows/src/handlers/cart/remove-cart.ts b/packages/workflows/src/handlers/cart/remove-cart.ts new file mode 100644 index 0000000000..77598771ce --- /dev/null +++ b/packages/workflows/src/handlers/cart/remove-cart.ts @@ -0,0 +1,28 @@ +import { WorkflowArguments } from "../../helper" + +enum Aliases { + Cart = "cart", +} + +type HandlerInputData = { + cart: { + id: string + } +} + +export async function removeCart({ + container, + context, + data, +}: WorkflowArguments): Promise { + const { manager } = context + + const cartService = container.resolve("cartService") + + const cartServiceTx = cartService.withTransaction(manager) + const cart = data[Aliases.Cart] + + await cartServiceTx.delete(cart.id) +} + +removeCart.aliases = Aliases diff --git a/packages/workflows/src/handlers/cart/retrieve-cart.ts b/packages/workflows/src/handlers/cart/retrieve-cart.ts new file mode 100644 index 0000000000..793704b1cc --- /dev/null +++ b/packages/workflows/src/handlers/cart/retrieve-cart.ts @@ -0,0 +1,40 @@ +import { CartDTO } from "@medusajs/types" +import { WorkflowArguments } from "../../helper" + +type HandlerInputData = { + cart: { + id: string + } + config: { + retrieveConfig: { + select: string[] + relations: string[] + } + } +} + +enum Aliases { + Cart = "cart", + Config = "config", +} + +export async function retrieveCart({ + container, + context, + data, +}: WorkflowArguments): Promise { + const { manager } = context + + const cartService = container.resolve("cartService") + + const cartServiceTx = cartService.withTransaction(manager) + + const retrieved = await cartServiceTx.retrieve( + data[Aliases.Cart].id, + data[Aliases.Config].retrieveConfig + ) + + return retrieved +} + +retrieveCart.aliases = Aliases diff --git a/packages/workflows/src/handlers/common/index.ts b/packages/workflows/src/handlers/common/index.ts new file mode 100644 index 0000000000..b5c1adca54 --- /dev/null +++ b/packages/workflows/src/handlers/common/index.ts @@ -0,0 +1,2 @@ +export * from "./set-config" +export * from "./set-context" diff --git a/packages/workflows/src/handlers/common/set-config.ts b/packages/workflows/src/handlers/common/set-config.ts new file mode 100644 index 0000000000..75c505c6ed --- /dev/null +++ b/packages/workflows/src/handlers/common/set-config.ts @@ -0,0 +1,31 @@ +import { WorkflowArguments } from "../../helper" + +type ConfigDTO = { + retrieveConfig?: { + select?: string[] + relations?: string[] + } +} + +enum Aliases { + Config = "config", +} + +type HandlerInputData = { + config: { + retrieveConfig?: { + select?: string[] + relations?: string[] + } + } +} + +export async function setConfig({ + data, +}: WorkflowArguments): Promise { + return { + retrieveConfig: data[Aliases.Config].retrieveConfig, + } +} + +setConfig.aliases = Aliases diff --git a/packages/workflows/src/handlers/common/set-context.ts b/packages/workflows/src/handlers/common/set-context.ts new file mode 100644 index 0000000000..63e0701d90 --- /dev/null +++ b/packages/workflows/src/handlers/common/set-context.ts @@ -0,0 +1,27 @@ +import { WorkflowArguments } from "../../helper" + +type ContextDTO = { + context?: Record +} + +enum Aliases { + Context = "context", +} + +type HandlerInputData = { + context: { + context?: Record + } +} + +export async function setContext({ + data, +}: WorkflowArguments): Promise { + const contextDTO: ContextDTO = { + context: data[Aliases.Context].context, + } + + return contextDTO +} + +setContext.aliases = Aliases diff --git a/packages/workflows/src/handlers/customer/find-or-create-customer.ts b/packages/workflows/src/handlers/customer/find-or-create-customer.ts new file mode 100644 index 0000000000..d271b373c7 --- /dev/null +++ b/packages/workflows/src/handlers/customer/find-or-create-customer.ts @@ -0,0 +1,63 @@ +import { validateEmail } from "@medusajs/utils" + +import { WorkflowArguments } from "../../helper" + +type CustomerDTO = { + customer_id?: string + email?: string +} + +type HandlerInputData = { + customer: { + customer_id?: string + email?: string + } +} + +enum Aliases { + Customer = "customer", +} + +export async function findOrCreateCustomer({ + container, + context, + data, +}: WorkflowArguments): Promise { + const { manager } = context + + const customerService = container.resolve("customerService") + + const customerDTO: CustomerDTO = {} + const customerId = data[Aliases.Customer].customer_id + const customerServiceTx = customerService.withTransaction(manager) + + if (customerId) { + const customer = await customerServiceTx + .retrieve(customerId) + .catch(() => undefined) + + customerDTO.customer_id = customer?.id + customerDTO.email = customer?.email + } + + const customerEmail = data[Aliases.Customer].email + + if (customerEmail) { + const validatedEmail = validateEmail(customerEmail) + + let customer = await customerServiceTx + .retrieveUnregisteredByEmail(validatedEmail) + .catch(() => undefined) + + if (!customer) { + customer = await customerServiceTx.create({ email: validatedEmail }) + } + + customerDTO.customer_id = customer.id + customerDTO.email = customer.email + } + + return customerDTO +} + +findOrCreateCustomer.aliases = Aliases diff --git a/packages/workflows/src/handlers/customer/index.ts b/packages/workflows/src/handlers/customer/index.ts new file mode 100644 index 0000000000..d801bb336d --- /dev/null +++ b/packages/workflows/src/handlers/customer/index.ts @@ -0,0 +1 @@ +export * from "./find-or-create-customer" diff --git a/packages/workflows/src/handlers/index.ts b/packages/workflows/src/handlers/index.ts index 4b2c9a34c4..5c0282342f 100644 --- a/packages/workflows/src/handlers/index.ts +++ b/packages/workflows/src/handlers/index.ts @@ -1,3 +1,9 @@ -export * as ProductHandlers from "./product" +export * as AddressHandlers from "./address" +export * as CartHandlers from "./cart" +export * as CommonHandlers from "./common" +export * as CustomerHandlers from "./customer" export * as InventoryHandlers from "./inventory" export * as MiddlewaresHandlers from "./middlewares" +export * as ProductHandlers from "./product" +export * as RegionHandlers from "./region" +export * as SalesChannelHandlers from "./sales-channel" diff --git a/packages/workflows/src/handlers/region/find-region.ts b/packages/workflows/src/handlers/region/find-region.ts new file mode 100644 index 0000000000..02b7ae2dba --- /dev/null +++ b/packages/workflows/src/handlers/region/find-region.ts @@ -0,0 +1,49 @@ +import { MedusaError } from "@medusajs/utils" +import { isDefined } from "medusa-core-utils" + +import { WorkflowArguments } from "../../helper" + +type RegionDTO = { + region_id?: string +} + +type HandlerInputData = { + region: { + region_id: string + } +} + +enum Aliases { + Region = "region", +} + +export async function findRegion({ + container, + data, +}: WorkflowArguments): Promise { + const regionService = container.resolve("regionService") + + let regionId: string + const regionDTO: RegionDTO = {} + + if (isDefined(data[Aliases.Region].region_id)) { + regionId = data[Aliases.Region].region_id + } else { + const regions = await regionService.list({}, {}) + + if (!regions?.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `A region is required to create a cart` + ) + } + + regionId = regions[0].id + } + + regionDTO.region_id = regionId + + return regionDTO +} + +findRegion.aliases = Aliases diff --git a/packages/workflows/src/handlers/region/index.ts b/packages/workflows/src/handlers/region/index.ts new file mode 100644 index 0000000000..c810009395 --- /dev/null +++ b/packages/workflows/src/handlers/region/index.ts @@ -0,0 +1 @@ +export * from "./find-region" diff --git a/packages/workflows/src/handlers/sales-channel/find-sales-channel.ts b/packages/workflows/src/handlers/sales-channel/find-sales-channel.ts new file mode 100644 index 0000000000..b73a93b6e3 --- /dev/null +++ b/packages/workflows/src/handlers/sales-channel/find-sales-channel.ts @@ -0,0 +1,74 @@ +import { MedusaError } from "@medusajs/utils" +import { isDefined } from "medusa-core-utils" + +import { WorkflowArguments } from "../../helper" + +type AttachSalesChannelDTO = { + sales_channel_id?: string +} + +type HandlerInputData = { + sales_channel: { + sales_channel_id?: string + publishableApiKeyScopes?: { + sales_channel_ids?: string[] + } + } +} + +enum Aliases { + SalesChannel = "sales_channel", +} + +export async function findSalesChannel({ + container, + data, +}: WorkflowArguments): Promise { + const salesChannelService = container.resolve("salesChannelService") + const storeService = container.resolve("storeService") + + let salesChannelId = data[Aliases.SalesChannel].sales_channel_id + let salesChannel + const salesChannelDTO: AttachSalesChannelDTO = {} + const publishableApiKeyScopes = + data[Aliases.SalesChannel].publishableApiKeyScopes || {} + + delete data[Aliases.SalesChannel].publishableApiKeyScopes + + if ( + !isDefined(salesChannelId) && + publishableApiKeyScopes?.sales_channel_ids?.length + ) { + if (publishableApiKeyScopes.sales_channel_ids.length > 1) { + throw new MedusaError( + MedusaError.Types.UNEXPECTED_STATE, + "The provided PublishableApiKey has multiple associated sales channels." + ) + } + + salesChannelId = publishableApiKeyScopes.sales_channel_ids[0] + } + + if (isDefined(salesChannelId)) { + salesChannel = await salesChannelService.retrieve(salesChannelId) + } else { + salesChannel = ( + await storeService.retrieve({ + relations: ["default_sales_channel"], + }) + ).default_sales_channel + } + + if (salesChannel.is_disabled) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Unable to assign the cart to a disabled Sales Channel "${salesChannel.name}"` + ) + } + + salesChannelDTO.sales_channel_id = salesChannel?.id + + return salesChannelDTO +} + +findSalesChannel.aliases = Aliases diff --git a/packages/workflows/src/handlers/sales-channel/index.ts b/packages/workflows/src/handlers/sales-channel/index.ts new file mode 100644 index 0000000000..85ddd852c0 --- /dev/null +++ b/packages/workflows/src/handlers/sales-channel/index.ts @@ -0,0 +1 @@ +export * from "./find-sales-channel" diff --git a/packages/workflows/src/helper/index.ts b/packages/workflows/src/helper/index.ts index 4442dddba1..6382e76182 100644 --- a/packages/workflows/src/helper/index.ts +++ b/packages/workflows/src/helper/index.ts @@ -1,3 +1,4 @@ +export * from "./aggregate" export * from "./empty-handler" export * from "./pipe" export * from "./workflow-export"