feat: CartRegion link, definition + workflow (#6392)

This commit is contained in:
Oli Juhl
2024-02-16 15:06:26 +01:00
committed by GitHub
parent 7df4947ecf
commit 24fb102a56
17 changed files with 495 additions and 218 deletions

View File

@@ -0,0 +1,9 @@
---
"@medusajs/medusa": patch
"@medusajs/core-flows": patch
"@medusajs/link-modules": patch
"@medusajs/modules-sdk": patch
"@medusajs/types": patch
---
feat: CartRegion link, definition + workflow

View File

@@ -0,0 +1,166 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICartModuleService, IRegionModuleService } from "@medusajs/types"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
describe("POST /store/carts", () => {
let dbConnection
let appContainer
let shutdownServer
let cartModuleService: ICartModuleService
let regionModuleService: IRegionModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
cartModuleService = appContainer.resolve(ModuleRegistrationName.CART)
regionModuleService = appContainer.resolve(ModuleRegistrationName.REGION)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
// @ts-ignore
await regionModuleService.createDefaultCountriesAndCurrencies()
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should create a cart", async () => {
const region = await regionModuleService.create({
name: "US",
currency_code: "usd",
})
const api = useApi() as any
const response = await api.post(`/store/carts`, {
email: "tony@stark.com",
currency_code: "usd",
region_id: region.id,
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: response.data.cart.id,
currency_code: "usd",
email: "tony@stark.com",
region: expect.objectContaining({
id: region.id,
currency_code: "usd",
}),
})
)
})
it("should use any region", async () => {
await regionModuleService.create({
name: "US",
currency_code: "usd",
})
const api = useApi() as any
const response = await api.post(`/store/carts`, {
email: "tony@stark.com",
currency_code: "usd",
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: response.data.cart.id,
currency_code: "usd",
email: "tony@stark.com",
region: expect.objectContaining({
id: expect.any(String),
}),
})
)
})
it("should use region currency code", async () => {
await regionModuleService.create({
name: "US",
currency_code: "usd",
})
const api = useApi() as any
const response = await api.post(`/store/carts`, {
email: "tony@stark.com",
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: response.data.cart.id,
currency_code: "usd",
email: "tony@stark.com",
region: expect.objectContaining({
id: expect.any(String),
}),
})
)
})
it("should throw when no regions exist", async () => {
const api = useApi() as any
await expect(
api.post(`/store/carts`, {
email: "tony@stark.com",
currency_code: "usd",
})
).rejects.toThrow()
})
it("should create a cart", async () => {
const region = await regionModuleService.create({
name: "US",
currency_code: "usd",
})
await regionModuleService.create({
name: "Europe",
currency_code: "eur",
})
const api = useApi() as any
const response = await api.post(`/store/carts`, {
email: "tony@stark.com",
currency_code: "usd",
region_id: region.id,
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: response.data.cart.id,
currency_code: "usd",
email: "tony@stark.com",
region: expect.objectContaining({
id: region.id,
currency_code: "usd",
}),
})
)
})
})

View File

@@ -1,59 +0,0 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICartModuleService } from "@medusajs/types"
import path from "path"
import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../../environment-helpers/use-api"
import { getContainer } from "../../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../../environment-helpers/use-db"
import adminSeeder from "../../../../helpers/admin-seeder"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
describe("POST /store/carts", () => {
let dbConnection
let appContainer
let shutdownServer
let cartModuleService: ICartModuleService
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
cartModuleService = appContainer.resolve(ModuleRegistrationName.CART)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should create a cart", async () => {
const api = useApi() as any
const response = await api.post(`/store/carts`, {
email: "tony@stark.com",
currency_code: "usd",
})
expect(response.status).toEqual(200)
expect(response.data.cart).toEqual(
expect.objectContaining({
id: response.data.cart.id,
currency_code: "usd",
email: "tony@stark.com",
})
)
})
})

View File

@@ -0,0 +1,96 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { ICartModuleService, IRegionModuleService } from "@medusajs/types"
import path from "path"
import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app"
import { getContainer } from "../../../environment-helpers/use-container"
import { initDb, useDb } from "../../../environment-helpers/use-db"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
describe("Link: Cart Region", () => {
let dbConnection
let appContainer
let shutdownServer
let cartModuleService: ICartModuleService
let regionModule: IRegionModuleService
let remoteQuery
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
appContainer = getContainer()
cartModuleService = appContainer.resolve(ModuleRegistrationName.CART)
regionModule = appContainer.resolve(ModuleRegistrationName.REGION)
remoteQuery = appContainer.resolve("remoteQuery")
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
beforeEach(async () => {
// @ts-ignore
await regionModule.createDefaultCountriesAndCurrencies()
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should query carts and regions with remote query", async () => {
const region = await regionModule.create({
name: "Region",
currency_code: "usd",
})
const cart = await cartModuleService.create({
email: "tony@stark.com",
currency_code: "usd",
region_id: region.id,
})
const carts = await remoteQuery({
cart: {
fields: ["id"],
region: {
fields: ["id"],
},
},
})
const regions = await remoteQuery({
region: {
fields: ["id"],
carts: {
fields: ["id"],
},
},
})
expect(carts).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: cart.id,
region: expect.objectContaining({ id: region.id }),
}),
])
)
expect(regions).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: region.id,
carts: expect.arrayContaining([
expect.objectContaining({ id: cart.id }),
]),
}),
])
)
})
})

View File

@@ -0,0 +1,27 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IRegionModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const findOneOrAnyRegionStepId = "find-one-or-any-region"
export const findOneOrAnyRegionStep = createStep(
findOneOrAnyRegionStepId,
async (data: { regionId?: string }, { container }) => {
const service = container.resolve<IRegionModuleService>(
ModuleRegistrationName.REGION
)
if (!data.regionId) {
const regions = await service.list({})
if (!regions?.length) {
throw Error("No regions found")
}
return new StepResponse(regions[0])
}
const region = await service.retrieve(data.regionId)
return new StepResponse(region)
}
)

View File

@@ -1,3 +1,4 @@
export * from "./create-carts"
export * from "./find-one-or-any-region"
export * from "./update-carts"

View File

@@ -1,13 +1,31 @@
import { CartDTO, CreateCartDTO } from "@medusajs/types"
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createCartsStep } from "../steps"
import { CartDTO, CreateCartWorkflowInputDTO } from "@medusajs/types"
import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import { createCartsStep, findOneOrAnyRegionStep } from "../steps"
type WorkflowInput = { cartData: CreateCartDTO[] }
type WorkflowInput = CreateCartWorkflowInputDTO
export const createCartsWorkflowId = "create-carts"
export const createCartsWorkflow = createWorkflow(
createCartsWorkflowId,
export const createCartWorkflowId = "create-cart"
export const createCartWorkflow = createWorkflow(
createCartWorkflowId,
(input: WorkflowData<WorkflowInput>): WorkflowData<CartDTO[]> => {
return createCartsStep(input.cartData)
const region = findOneOrAnyRegionStep({
regionId: input.region_id,
})
const cartInput = transform({ input, region }, (data) => {
return {
...data.input,
currency_code: data?.input.currency_code || data.region.currency_code,
region_id: data.region.id,
}
})
const cart = createCartsStep([cartInput])
return cart
}
)

View File

@@ -0,0 +1,28 @@
import { Modules } from "@medusajs/modules-sdk"
import { ModuleJoinerConfig } from "@medusajs/types"
export const CartRegion: ModuleJoinerConfig = {
isLink: true,
isReadOnlyLink: true,
extends: [
{
serviceName: Modules.CART,
relationship: {
serviceName: Modules.REGION,
primaryKey: "id",
foreignKey: "region_id",
alias: "region",
},
},
{
serviceName: Modules.REGION,
relationship: {
serviceName: Modules.CART,
primaryKey: "region_id",
foreignKey: "id",
alias: "carts",
isList: true,
},
},
],
}

View File

@@ -1,8 +1,10 @@
export * from "./cart-region"
export * from "./cart-sales-channel"
export * from "./inventory-level-stock-location"
export * from "./order-sales-channel"
export * from "./product-sales-channel"
export * from "./product-shipping-profile"
export * from "./product-variant-inventory-item"
export * from "./product-variant-price-set"
export * from "./product-shipping-profile"
export * from "./product-sales-channel"
export * from "./cart-sales-channel"
export * from "./order-sales-channel"
export * from "./publishable-api-key-sales-channel"

View File

@@ -11,12 +11,11 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const query = {
cart: {
__args: variables,
...defaultStoreCartRemoteQueryObject,
},
}
const [cart] = await remoteQuery(query)
const [cart] = await remoteQuery(query, { cart: variables })
res.json({ cart })
}

View File

@@ -9,6 +9,7 @@ export const defaultStoreCartFields = [
export const defaultStoreCartRelations = [
"items",
"region",
"shipping_address",
"billing_address",
"shipping_methods",
@@ -61,4 +62,7 @@ export const defaultStoreCartRemoteQueryObject = {
"phone",
],
},
region: {
fields: ["id", "name", "currency_code"],
},
}

View File

@@ -1,17 +1,13 @@
import { createCartsWorkflow } from "@medusajs/core-flows"
import { createCartWorkflow } from "@medusajs/core-flows"
import { CreateCartDTO } from "@medusajs/types"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
import { defaultStoreCartRemoteQueryObject } from "../carts/query-config"
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const createCartWorkflow = createCartsWorkflow(req.scope)
const cartData = [
{
...(req.validatedBody as CreateCartDTO),
},
]
const workflow = createCartWorkflow(req.scope)
const { result, errors } = await createCartWorkflow.run({
input: { cartData },
const { result, errors } = await workflow.run({
input: req.validatedBody as CreateCartDTO,
throwOnError: false,
})
@@ -19,5 +15,17 @@ export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
throw errors[0].error
}
res.status(200).json({ cart: result[0] })
const remoteQuery = req.scope.resolve("remoteQuery")
const variables = { id: result[0].id }
const query = {
cart: {
...defaultStoreCartRemoteQueryObject,
},
}
const [cart] = await remoteQuery(query, { cart: variables })
res.status(200).json({ cart })
}

View File

@@ -1,7 +1,3 @@
import {
createCart as createCartWorkflow,
Workflows,
} from "@medusajs/core-flows"
import {
IsArray,
IsInt,
@@ -10,7 +6,7 @@ import {
IsString,
ValidateNested,
} from "class-validator"
import { isDefined, MedusaError } from "medusa-core-utils"
import { MedusaError, isDefined } from "medusa-core-utils"
import { defaultStoreCartFields, defaultStoreCartRelations } from "."
import {
CartService,
@@ -19,7 +15,6 @@ import {
RegionService,
} from "../../../../services"
import { MedusaContainer } from "@medusajs/modules-sdk"
import { FlagRouter } from "@medusajs/utils"
import { Type } from "class-transformer"
import reqIp from "request-ip"
@@ -122,130 +117,96 @@ export default async (req, res) => {
user_agent: req.get("user-agent"),
}
const isWorkflowEnabled = featureFlagRouter.isFeatureEnabled({
workflows: Workflows.CreateCart,
})
const lineItemService: LineItemService = req.scope.resolve("lineItemService")
const regionService: RegionService = req.scope.resolve("regionService")
let cart
if (isWorkflowEnabled) {
const cartWorkflow = createCartWorkflow(req.scope as MedusaContainer)
const input = {
...validated,
publishableApiKeyScopes: req.publishableApiKeyScopes,
context: {
...reqContext,
...validated.context,
},
}
const { result, errors } = await cartWorkflow.run({
input,
context: {
manager: entityManager,
},
throwOnError: false,
})
if (Array.isArray(errors)) {
if (isDefined(errors[0])) {
throw errors[0].error
}
}
cart = result
let regionId!: string
if (isDefined(validated.region_id)) {
regionId = validated.region_id as string
} else {
const lineItemService: LineItemService =
req.scope.resolve("lineItemService")
const regionService: RegionService = req.scope.resolve("regionService")
const regions = await regionService.list({})
let regionId!: string
if (isDefined(validated.region_id)) {
regionId = validated.region_id as string
} 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
if (!regions?.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`A region is required to create a cart`
)
}
const toCreate: Partial<CartCreateProps> = {
region_id: regionId,
sales_channel_id: validated.sales_channel_id,
context: {
...reqContext,
...validated.context,
},
}
if (req.user && req.user.customer_id) {
const customerService = req.scope.resolve("customerService")
const customer = await customerService.retrieve(req.user.customer_id)
toCreate["customer_id"] = customer.id
toCreate["email"] = customer.email
}
if (validated.country_code) {
toCreate["shipping_address"] = {
country_code: validated.country_code.toLowerCase(),
}
}
if (
!toCreate.sales_channel_id &&
req.publishableApiKeyScopes?.sales_channel_ids.length
) {
if (req.publishableApiKeyScopes.sales_channel_ids.length > 1) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"The PublishableApiKey provided in the request header has multiple associated sales channels."
)
}
toCreate.sales_channel_id =
req.publishableApiKeyScopes.sales_channel_ids[0]
}
cart = await entityManager.transaction(async (manager) => {
const cartServiceTx = cartService.withTransaction(manager)
const lineItemServiceTx = lineItemService.withTransaction(manager)
const createdCart = await cartServiceTx.create(toCreate)
if (validated.items?.length) {
const generateInputData = validated.items.map((item) => {
return {
variantId: item.variant_id,
quantity: item.quantity,
}
})
const generatedLineItems: LineItem[] = await lineItemServiceTx.generate(
generateInputData,
{
region_id: regionId,
customer_id: req.user?.customer_id,
}
)
await cartServiceTx.addOrUpdateLineItems(
createdCart.id,
generatedLineItems,
{
validateSalesChannels:
featureFlagRouter.isFeatureEnabled("sales_channels"),
}
)
}
return createdCart
})
regionId = regions[0].id
}
const toCreate: Partial<CartCreateProps> = {
region_id: regionId,
sales_channel_id: validated.sales_channel_id,
context: {
...reqContext,
...validated.context,
},
}
if (req.user && req.user.customer_id) {
const customerService = req.scope.resolve("customerService")
const customer = await customerService.retrieve(req.user.customer_id)
toCreate["customer_id"] = customer.id
toCreate["email"] = customer.email
}
if (validated.country_code) {
toCreate["shipping_address"] = {
country_code: validated.country_code.toLowerCase(),
}
}
if (
!toCreate.sales_channel_id &&
req.publishableApiKeyScopes?.sales_channel_ids.length
) {
if (req.publishableApiKeyScopes.sales_channel_ids.length > 1) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
"The PublishableApiKey provided in the request header has multiple associated sales channels."
)
}
toCreate.sales_channel_id = req.publishableApiKeyScopes.sales_channel_ids[0]
}
let cart = await entityManager.transaction(async (manager) => {
const cartServiceTx = cartService.withTransaction(manager)
const lineItemServiceTx = lineItemService.withTransaction(manager)
const createdCart = await cartServiceTx.create(toCreate)
if (validated.items?.length) {
const generateInputData = validated.items.map((item) => {
return {
variantId: item.variant_id,
quantity: item.quantity,
}
})
const generatedLineItems: LineItem[] = await lineItemServiceTx.generate(
generateInputData,
{
region_id: regionId,
customer_id: req.user?.customer_id,
}
)
await cartServiceTx.addOrUpdateLineItems(
createdCart.id,
generatedLineItems,
{
validateSalesChannels:
featureFlagRouter.isFeatureEnabled("sales_channels"),
}
)
}
return createdCart
})
// }
cart = await cartService.retrieveWithTotals(cart!.id, {
select: defaultStoreCartFields,
relations: defaultStoreCartRelations,

View File

@@ -293,16 +293,13 @@ async function MedusaApp_({
allowUnregistered: true,
})
const {
remoteLink,
linkResolution,
runMigrations: linkModuleMigration,
} = await initializeLinks({
config: linkModuleOptions,
linkModules,
injectedDependencies,
moduleExports: isMedusaModule(linkModule) ? linkModule : undefined,
})
const { remoteLink, runMigrations: linkModuleMigration } =
await initializeLinks({
config: linkModuleOptions,
linkModules,
injectedDependencies,
moduleExports: isMedusaModule(linkModule) ? linkModule : undefined,
})
const loadedSchema = getLoadedSchema()
const { schema, notFound } = cleanAndMergeSchema(loadedSchema)

View File

@@ -1,5 +1,5 @@
import { ModuleExports } from "@medusajs/types"
import { RegionModuleService } from "@services"
import { RegionModuleService } from "./services"
import { Modules } from "@medusajs/modules-sdk"
import { ModulesSdkUtils } from "@medusajs/utils"

View File

@@ -1,4 +1,4 @@
export * from "./common"
export * from "./mutations"
export * from "./service"
export * from "./workflows"

View File

@@ -0,0 +1,20 @@
import {
CreateAddressDTO,
CreateLineItemDTO,
UpdateAddressDTO,
} from "./mutations"
export interface CreateCartWorkflowInputDTO {
region_id?: string
customer_id?: string
sales_channel_id?: string
email?: string
currency_code: string
shipping_address_id?: string
billing_address_id?: string
shipping_address?: CreateAddressDTO | UpdateAddressDTO
billing_address?: CreateAddressDTO | UpdateAddressDTO
metadata?: Record<string, unknown>
items?: CreateLineItemDTO[]
}