diff --git a/integration-tests/api/__tests__/admin/stock-location/index.spec.ts b/integration-tests/api/__tests__/admin/stock-location/index.spec.ts index a0fccaf034..6b4ae025af 100644 --- a/integration-tests/api/__tests__/admin/stock-location/index.spec.ts +++ b/integration-tests/api/__tests__/admin/stock-location/index.spec.ts @@ -4,8 +4,8 @@ import { createAdminUser, } from "../../../../helpers/create-admin-user" -import { ContainerRegistrationKeys } from "@medusajs/utils" import { IStockLocationServiceNext } from "@medusajs/types" +import { ContainerRegistrationKeys } from "@medusajs/utils" const { medusaIntegrationTestRunner } = require("medusa-test-utils") @@ -374,5 +374,59 @@ medusaIntegrationTestRunner({ ).toHaveLength(1) }) }) + + describe("Location fulfillment sets", () => { + let stockLocationId + + beforeEach(async () => { + const createResponse = await api.post( + `/admin/stock-locations`, + { + name: "test location", + }, + adminHeaders + ) + + stockLocationId = createResponse.data.stock_location.id + }) + + it("should create a fulfillment set for the location", async () => { + const response = await api.post( + `/admin/stock-locations/${stockLocationId}/fulfillment-sets?fields=id,*fulfillment_sets`, + { + name: "Fulfillment Set", + type: "shipping", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + expect(response.data.stock_location.fulfillment_sets).toEqual([ + expect.objectContaining({ + id: expect.any(String), + }), + ]) + }) + + // This is really just to test the new Zod middleware. We don't need more of these. + it("should throw a validation error on wrong input", async () => { + const errorResponse = await api + .post( + `/admin/stock-locations/${stockLocationId}/fulfillment-sets?fields=id,*fulfillment_sets`, + { + name: "Fulfillment Set", + type: "shipping", + foo: "bar", + }, + adminHeaders + ) + .catch((e) => e.response) + + expect(errorResponse.status).toEqual(400) + + expect(errorResponse.data.message).toContain("Invalid request body: ") + }) + }) }, }) diff --git a/integration-tests/api/medusa-config.js b/integration-tests/api/medusa-config.js index a87c4d7a7f..6cd153c227 100644 --- a/integration-tests/api/medusa-config.js +++ b/integration-tests/api/medusa-config.js @@ -84,6 +84,7 @@ module.exports = { [Modules.TAX]: true, [Modules.CURRENCY]: true, [Modules.PAYMENT]: true, + [Modules.FULFILLMENT]: true, } : {}), }, diff --git a/packages/core-flows/src/fulfillment/steps/create-fulfillment-set.ts b/packages/core-flows/src/fulfillment/steps/create-fulfillment-set.ts new file mode 100644 index 0000000000..0bd01fc063 --- /dev/null +++ b/packages/core-flows/src/fulfillment/steps/create-fulfillment-set.ts @@ -0,0 +1,34 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CreateFulfillmentSetDTO, + IFulfillmentModuleService, +} from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +export const createFulfillmentSetsId = "create-fulfillment-sets" +export const createFulfillmentSets = createStep( + createFulfillmentSetsId, + async (data: CreateFulfillmentSetDTO[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + const createSets = await service.create(data) + + return new StepResponse( + createSets, + createSets.map((createdSet) => createdSet.id) + ) + }, + async (createSetIds, { container }) => { + if (!createSetIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + await service.delete(createSetIds) + } +) diff --git a/packages/core-flows/src/fulfillment/steps/index.ts b/packages/core-flows/src/fulfillment/steps/index.ts index 957da3d401..e00c0be1ae 100644 --- a/packages/core-flows/src/fulfillment/steps/index.ts +++ b/packages/core-flows/src/fulfillment/steps/index.ts @@ -1,2 +1,3 @@ export * from "./add-rules-to-fulfillment-shipping-option" +export * from "./create-fulfillment-set" export * from "./remove-rules-from-fulfillment-shipping-option" diff --git a/packages/core-flows/src/stock-location/steps/associate-locations-with-fulfillment-sets.ts b/packages/core-flows/src/stock-location/steps/associate-locations-with-fulfillment-sets.ts new file mode 100644 index 0000000000..c9c539b140 --- /dev/null +++ b/packages/core-flows/src/stock-location/steps/associate-locations-with-fulfillment-sets.ts @@ -0,0 +1,52 @@ +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +import { Modules } from "@medusajs/modules-sdk" +import { ContainerRegistrationKeys } from "@medusajs/utils" + +interface StepInput { + input: { + location_id: string + fulfillment_set_ids: string[] + }[] +} + +export const associateFulfillmentSetsWithLocationStepId = + "associate-fulfillment-sets-with-location-step" +export const associateFulfillmentSetsWithLocationStep = createStep( + associateFulfillmentSetsWithLocationStepId, + async (data: StepInput, { container }) => { + if (!data.input.length) { + return new StepResponse([], []) + } + + const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK) + + const links = data.input + .map((link) => { + return link.fulfillment_set_ids.map((id) => { + return { + [Modules.FULFILLMENT]: { + fulfillment_set_id: id, + }, + [Modules.STOCK_LOCATION]: { + stock_location_id: link.location_id, + }, + } + }) + }) + .flat() + + const createdLinks = await remoteLink.create(links) + + return new StepResponse(createdLinks, links) + }, + async (links, { container }) => { + if (!links?.length) { + return + } + + const remoteLink = container.resolve(ContainerRegistrationKeys.REMOTE_LINK) + + await remoteLink.dismiss(links) + } +) diff --git a/packages/core-flows/src/stock-location/workflows/create-location-fulfillment-set.ts b/packages/core-flows/src/stock-location/workflows/create-location-fulfillment-set.ts new file mode 100644 index 0000000000..9a90b892d6 --- /dev/null +++ b/packages/core-flows/src/stock-location/workflows/create-location-fulfillment-set.ts @@ -0,0 +1,33 @@ +import { CreateLocationFulfillmentSetWorkflowInputDTO } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { createFulfillmentSets } from "../../fulfillment" +import { associateFulfillmentSetsWithLocationStep } from "../steps/associate-locations-with-fulfillment-sets" + +export const createLocationFulfillmentSetWorkflowId = + "create-location-fulfillment-set" +export const createLocationFulfillmentSetWorkflow = createWorkflow( + createLocationFulfillmentSetWorkflowId, + (input: WorkflowData) => { + const fulfillmentSet = createFulfillmentSets([ + { + name: input.fulfillment_set_data.name, + type: input.fulfillment_set_data.type, + }, + ]) + + const data = transform({ input, fulfillmentSet }, (data) => [ + { + location_id: data.input.location_id, + fulfillment_set_ids: [data.fulfillmentSet[0].id], + }, + ]) + + associateFulfillmentSetsWithLocationStep({ + input: data, + }) + } +) diff --git a/packages/core-flows/src/stock-location/workflows/index.ts b/packages/core-flows/src/stock-location/workflows/index.ts index 73d2e82ffc..d32b09bb0f 100644 --- a/packages/core-flows/src/stock-location/workflows/index.ts +++ b/packages/core-flows/src/stock-location/workflows/index.ts @@ -1,3 +1,4 @@ +export * from "./create-location-fulfillment-set" export * from "./create-stock-locations" -export * from "./update-stock-locations" export * from "./delete-stock-locations" +export * from "./update-stock-locations" diff --git a/packages/medusa/src/api-v2/admin/stock-locations/[id]/fulfillment-sets/route.ts b/packages/medusa/src/api-v2/admin/stock-locations/[id]/fulfillment-sets/route.ts new file mode 100644 index 0000000000..3691b29a44 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/stock-locations/[id]/fulfillment-sets/route.ts @@ -0,0 +1,42 @@ +import { createLocationFulfillmentSetWorkflow } from "@medusajs/core-flows" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { z } from "zod" +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" +import { AdminCreateStockLocationFulfillmentSet } from "../../validators" + +export const POST = async ( + req: MedusaRequest>, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const { errors } = await createLocationFulfillmentSetWorkflow(req.scope).run({ + input: { + location_id: req.params.id, + fulfillment_set_data: { + name: req.validatedBody.name, + type: req.validatedBody.type, + }, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const [stock_location] = await remoteQuery( + remoteQueryObjectFromString({ + entryPoint: "stock_locations", + variables: { + id: req.params.id, + }, + fields: req.remoteQueryConfig.fields, + }) + ) + + res.status(200).json({ stock_location }) +} diff --git a/packages/medusa/src/api-v2/admin/stock-locations/middlewares.ts b/packages/medusa/src/api-v2/admin/stock-locations/middlewares.ts index 382342caed..4ca443545e 100644 --- a/packages/medusa/src/api-v2/admin/stock-locations/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/stock-locations/middlewares.ts @@ -1,6 +1,8 @@ import * as QueryConfig from "./query-config" +import { transformBody, transformQuery } from "../../../api/middlewares" import { + AdminCreateStockLocationFulfillmentSet, AdminGetStockLocationsLocationParams, AdminGetStockLocationsParams, AdminPostStockLocationsLocationParams, @@ -9,11 +11,11 @@ import { AdminPostStockLocationsReq, AdminStockLocationsLocationSalesChannelBatchReq, } from "./validators" -import { transformBody, transformQuery } from "../../../api/middlewares" import { MiddlewareRoute } from "../../../types/middlewares" -import { applySalesChannelsFilter } from "./utils/apply-sales-channel-filter" import { authenticate } from "../../../utils/authenticate-middleware" +import { validateAndTransformBody } from "../../utils/validate-body" +import { applySalesChannelsFilter } from "./utils/apply-sales-channel-filter" export const adminStockLocationRoutesMiddlewares: MiddlewareRoute[] = [ { @@ -75,4 +77,15 @@ export const adminStockLocationRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/stock-locations/:id/fulfillment-sets", + middlewares: [ + validateAndTransformBody(AdminCreateStockLocationFulfillmentSet), + transformQuery( + AdminPostStockLocationsParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/stock-locations/validators.ts b/packages/medusa/src/api-v2/admin/stock-locations/validators.ts index 9589f9a36a..843d2acc64 100644 --- a/packages/medusa/src/api-v2/admin/stock-locations/validators.ts +++ b/packages/medusa/src/api-v2/admin/stock-locations/validators.ts @@ -1,4 +1,4 @@ -import { FindParams, extendedFindParamsMixin } from "../../../types/common" +import { Transform, Type } from "class-transformer" import { IsNotEmpty, IsObject, @@ -6,8 +6,9 @@ import { IsString, ValidateNested, } from "class-validator" -import { Transform, Type } from "class-transformer" +import { FindParams, extendedFindParamsMixin } from "../../../types/common" +import { z } from "zod" import { IsType } from "../../../utils" /** @@ -286,3 +287,10 @@ export class AdminStockLocationsLocationSalesChannelBatchReq { @IsString({ each: true }) sales_channel_ids: string[] } + +export const AdminCreateStockLocationFulfillmentSet = z + .object({ + name: z.string(), + type: z.string(), + }) + .strict() diff --git a/packages/medusa/src/types/routing.ts b/packages/medusa/src/types/routing.ts index 2a130056db..098281fa95 100644 --- a/packages/medusa/src/types/routing.ts +++ b/packages/medusa/src/types/routing.ts @@ -1,5 +1,5 @@ -import type { Customer, User } from "../models" import type { NextFunction, Request, Response } from "express" +import type { Customer, User } from "../models" import { MedusaContainer, RequestQueryFields } from "@medusajs/types" import { FindConfig } from "./common" diff --git a/packages/types/src/http/index.ts b/packages/types/src/http/index.ts index b3f8f6afd6..350412d35d 100644 --- a/packages/types/src/http/index.ts +++ b/packages/types/src/http/index.ts @@ -1 +1,2 @@ +export * from "./stock-locations" export * from "./fulfillment" diff --git a/packages/types/src/http/stock-locations/common.ts b/packages/types/src/http/stock-locations/common.ts new file mode 100644 index 0000000000..90309b66c5 --- /dev/null +++ b/packages/types/src/http/stock-locations/common.ts @@ -0,0 +1,30 @@ +import { AdminFulfillmentSetResponse } from "../fulfillment" + +export type AdminStockLocationAddressResponse = { + 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 +} + +export interface AdminStockLocationResponse { + id: string + name: string + metadata: Record | null + address_id: string + address?: AdminStockLocationAddressResponse + created_at: string | Date + updated_at: string | Date + deleted_at: string | Date | null + + fulfillment_sets?: AdminFulfillmentSetResponse[] +} diff --git a/packages/types/src/http/stock-locations/index.ts b/packages/types/src/http/stock-locations/index.ts new file mode 100644 index 0000000000..488a94fdff --- /dev/null +++ b/packages/types/src/http/stock-locations/index.ts @@ -0,0 +1 @@ +export * from "./common" diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 566c2b446b..248ba36942 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -5,12 +5,15 @@ export * from "./bundles" export * from "./cache" export * from "./cart" export * from "./common" +export * from "./currency" export * from "./customer" export * from "./dal" export * from "./event-bus" export * from "./feature-flag" +export * from "./file" export * from "./file-service" export * from "./fulfillment" +export * from "./http" export * from "./inventory" export * from "./joiner" export * from "./link-modules" @@ -34,6 +37,4 @@ export * from "./totals" export * from "./transaction-base" export * from "./user" export * from "./workflow" -export * from "./currency" -export * from "./http" -export * from "./file" +export * from "./workflows" diff --git a/packages/types/src/workflows/index.ts b/packages/types/src/workflows/index.ts new file mode 100644 index 0000000000..1f3c0fe2ae --- /dev/null +++ b/packages/types/src/workflows/index.ts @@ -0,0 +1 @@ +export * from "./stock-locations" diff --git a/packages/types/src/workflows/stock-locations/index.ts b/packages/types/src/workflows/stock-locations/index.ts new file mode 100644 index 0000000000..bd086bcaef --- /dev/null +++ b/packages/types/src/workflows/stock-locations/index.ts @@ -0,0 +1 @@ +export * from "./mutations" diff --git a/packages/types/src/workflows/stock-locations/mutations.ts b/packages/types/src/workflows/stock-locations/mutations.ts new file mode 100644 index 0000000000..ba2cac3c7a --- /dev/null +++ b/packages/types/src/workflows/stock-locations/mutations.ts @@ -0,0 +1,7 @@ +export interface CreateLocationFulfillmentSetWorkflowInputDTO { + location_id: string + fulfillment_set_data: { + name: string + type: string + } +}