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
This commit is contained in:
Riqwan Thamir
2023-08-08 12:10:27 +02:00
committed by GitHub
parent a42c41e8ab
commit 281b0746cf
35 changed files with 1140 additions and 13 deletions

View File

@@ -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<any, any>
export const createCart = exportWorkflow<
CartWorkflow.CreateCartWorkflowInputDTO,
CreateCartWorkflowOutput
>(Workflows.CreateCart, CreateCartActions.retrieveCart)

View File

@@ -0,0 +1 @@
export * from "./create-cart"

View File

@@ -1 +1,2 @@
export * from "./create-products"
export * from "./cart"

View File

@@ -1,5 +1,9 @@
export enum Workflows {
// Product workflows
CreateProducts = "create-products",
// Cart workflows
CreateCart = "create-cart",
}
export enum InputAlias {

View File

@@ -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<HandlerInputData>): Promise<AddressesDTO> {
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

View File

@@ -0,0 +1 @@
export * from "./find-or-create-addresses"

View File

@@ -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<HandlerInputData>): Promise<void> {
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

View File

@@ -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<any, any>
}
}
type HandlerOutputData = {
cart: CartDTO
}
export async function createCart({
container,
context,
data,
}: WorkflowArguments<HandlerInputData>): Promise<HandlerOutputData> {
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

View File

@@ -0,0 +1,4 @@
export * from "./attach-line-items-to-cart"
export * from "./create-cart"
export * from "./remove-cart"
export * from "./retrieve-cart"

View File

@@ -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<HandlerInputData>): Promise<void> {
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

View File

@@ -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<HandlerInputData>): Promise<CartDTO> {
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

View File

@@ -0,0 +1,2 @@
export * from "./set-config"
export * from "./set-context"

View File

@@ -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<HandlerInputData>): Promise<ConfigDTO> {
return {
retrieveConfig: data[Aliases.Config].retrieveConfig,
}
}
setConfig.aliases = Aliases

View File

@@ -0,0 +1,27 @@
import { WorkflowArguments } from "../../helper"
type ContextDTO = {
context?: Record<any, any>
}
enum Aliases {
Context = "context",
}
type HandlerInputData = {
context: {
context?: Record<any, any>
}
}
export async function setContext({
data,
}: WorkflowArguments<HandlerInputData>): Promise<ContextDTO> {
const contextDTO: ContextDTO = {
context: data[Aliases.Context].context,
}
return contextDTO
}
setContext.aliases = Aliases

View File

@@ -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<HandlerInputData>): Promise<CustomerDTO> {
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

View File

@@ -0,0 +1 @@
export * from "./find-or-create-customer"

View File

@@ -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"

View File

@@ -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<HandlerInputData>): Promise<RegionDTO> {
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

View File

@@ -0,0 +1 @@
export * from "./find-region"

View File

@@ -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<HandlerInputData>): Promise<AttachSalesChannelDTO> {
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

View File

@@ -0,0 +1 @@
export * from "./find-sales-channel"

View File

@@ -1,3 +1,4 @@
export * from "./aggregate"
export * from "./empty-handler"
export * from "./pipe"
export * from "./workflow-export"