diff --git a/integration-tests/api/__tests__/admin/fulfillment-sets.spec.ts b/integration-tests/api/__tests__/admin/fulfillment-sets.spec.ts new file mode 100644 index 0000000000..6e9c2d8f64 --- /dev/null +++ b/integration-tests/api/__tests__/admin/fulfillment-sets.spec.ts @@ -0,0 +1,254 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IFulfillmentModuleService } from "@medusajs/types" +import { + adminHeaders, + createAdminUser, +} from "../../../helpers/create-admin-user" + +const { medusaIntegrationTestRunner } = require("medusa-test-utils") + +jest.setTimeout(30000) + +medusaIntegrationTestRunner({ + env: { + MEDUSA_FF_MEDUSA_V2: true, + }, + testSuite: ({ dbConnection, getContainer, api }) => { + let appContainer + let service: IFulfillmentModuleService + + beforeEach(async () => { + appContainer = getContainer() + + await createAdminUser(dbConnection, adminHeaders, appContainer) + + service = appContainer.resolve(ModuleRegistrationName.STOCK_LOCATION) + }) + + describe("POST /admin/fulfillment-sets/:id/service-zones", () => { + it("should create a service zone for a fulfillment set", async () => { + const stockLocationResponse = await api.post( + `/admin/stock-locations`, + { + name: "test location", + }, + adminHeaders + ) + + const stockLocationId = stockLocationResponse.data.stock_location.id + + const locationWithFSetResponse = await api.post( + `/admin/stock-locations/${stockLocationId}/fulfillment-sets?fields=id,*fulfillment_sets`, + { + name: "Fulfillment Set", + type: "shipping", + }, + adminHeaders + ) + + const fulfillmentSetId = + locationWithFSetResponse.data.stock_location.fulfillment_sets[0].id + + const response = await api.post( + `/admin/fulfillment-sets/${fulfillmentSetId}/service-zones`, + { + name: "Test Zone", + geo_zones: [ + { + country_code: "dk", + type: "country", + }, + { + country_code: "fr", + type: "province", + province_code: "fr-idf", + }, + { + country_code: "it", + type: "city", + city: "some city", + province_code: "some-province", + }, + { + country_code: "it", + type: "zip", + city: "some city", + province_code: "some-province", + postal_expression: { type: "regex", exp: "00*" }, + }, + ], + }, + adminHeaders + ) + + const fset = response.data.fulfillment_set + + expect(response.status).toEqual(200) + expect(fset).toEqual( + expect.objectContaining({ + name: "Fulfillment Set", + type: "shipping", + service_zones: expect.arrayContaining([ + expect.objectContaining({ + name: "Test Zone", + fulfillment_set_id: fulfillmentSetId, + geo_zones: expect.arrayContaining([ + expect.objectContaining({ + country_code: "dk", + type: "country", + }), + expect.objectContaining({ + country_code: "fr", + type: "province", + province_code: "fr-idf", + }), + expect.objectContaining({ + country_code: "it", + type: "city", + city: "some city", + province_code: "some-province", + }), + expect.objectContaining({ + country_code: "it", + type: "zip", + city: "some city", + province_code: "some-province", + postal_expression: { type: "regex", exp: "00*" }, + }), + ]), + }), + ]), + }) + ) + }) + + it("should throw if invalid type is passed", async () => { + const stockLocationResponse = await api.post( + `/admin/stock-locations`, + { + name: "test location", + }, + adminHeaders + ) + + const stockLocationId = stockLocationResponse.data.stock_location.id + + const locationWithFSetResponse = await api.post( + `/admin/stock-locations/${stockLocationId}/fulfillment-sets?fields=id,*fulfillment_sets`, + { + name: "Fulfillment Set", + type: "shipping", + }, + adminHeaders + ) + + const fulfillmentSetId = + locationWithFSetResponse.data.stock_location.fulfillment_sets[0].id + + const errorResponse = await api + .post( + `/admin/fulfillment-sets/${fulfillmentSetId}/service-zones`, + { + name: "Test Zone", + geo_zones: [ + { + country_code: "dk", + type: "country", + }, + { + country_code: "fr", + type: "province", + province_code: "fr-idf", + }, + { + country_code: "it", + type: "region", + city: "some region", + province_code: "some-province", + }, + { + country_code: "it", + type: "zip", + city: "some city", + province_code: "some-province", + postal_expression: {}, + }, + ], + }, + adminHeaders + ) + .catch((err) => err.response) + + const expectedErrors = [ + { + code: "invalid_union", + unionErrors: [ + { + issues: [ + { + received: "region", + code: "invalid_literal", + expected: "country", + path: ["geo_zones", 2, "type"], + message: 'Invalid literal value, expected "country"', + }, + ], + name: "ZodError", + }, + { + issues: [ + { + received: "region", + code: "invalid_literal", + expected: "province", + path: ["geo_zones", 2, "type"], + message: 'Invalid literal value, expected "province"', + }, + ], + name: "ZodError", + }, + { + issues: [ + { + received: "region", + code: "invalid_literal", + expected: "city", + path: ["geo_zones", 2, "type"], + message: 'Invalid literal value, expected "city"', + }, + ], + name: "ZodError", + }, + { + issues: [ + { + received: "region", + code: "invalid_literal", + expected: "zip", + path: ["geo_zones", 2, "type"], + message: 'Invalid literal value, expected "zip"', + }, + { + code: "invalid_type", + expected: "object", + received: "undefined", + path: ["geo_zones", 2, "postal_expression"], + message: "Required", + }, + ], + name: "ZodError", + }, + ], + path: ["geo_zones", 2], + message: "Invalid input", + }, + ] + + expect(errorResponse.status).toEqual(400) + expect(errorResponse.data.message).toContain( + `Invalid request body: ${JSON.stringify(expectedErrors)}` + ) + }) + }) + }, +}) diff --git a/packages/core-flows/src/fulfillment/steps/create-service-zones.ts b/packages/core-flows/src/fulfillment/steps/create-service-zones.ts new file mode 100644 index 0000000000..14a98f7330 --- /dev/null +++ b/packages/core-flows/src/fulfillment/steps/create-service-zones.ts @@ -0,0 +1,36 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CreateServiceZoneDTO, + IFulfillmentModuleService, +} from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +type StepInput = CreateServiceZoneDTO[] + +export const createServiceZonesStepId = "create-service-zones" +export const createServiceZonesStep = createStep( + createServiceZonesStepId, + async (input: StepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + const createdServiceZones = await service.createServiceZones(input) + + return new StepResponse( + createdServiceZones, + createdServiceZones.map((createdZone) => createdZone.id) + ) + }, + async (createdServiceZones, { container }) => { + if (!createdServiceZones?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.FULFILLMENT + ) + + await service.deleteServiceZones(createdServiceZones) + } +) diff --git a/packages/core-flows/src/fulfillment/steps/index.ts b/packages/core-flows/src/fulfillment/steps/index.ts index 2a07f2c998..e725b41d7f 100644 --- a/packages/core-flows/src/fulfillment/steps/index.ts +++ b/packages/core-flows/src/fulfillment/steps/index.ts @@ -1,5 +1,6 @@ export * from "./add-rules-to-fulfillment-shipping-option" -export * from "./create-fulfillment-set" -export * from "./remove-rules-from-fulfillment-shipping-option" -export * from "./create-shipping-options" export * from "./add-shipping-options-prices" +export * from "./create-fulfillment-set" +export * from "./create-service-zones" +export * from "./create-shipping-options" +export * from "./remove-rules-from-fulfillment-shipping-option" diff --git a/packages/core-flows/src/fulfillment/workflows/create-service-zones.ts b/packages/core-flows/src/fulfillment/workflows/create-service-zones.ts new file mode 100644 index 0000000000..0fdc9e502f --- /dev/null +++ b/packages/core-flows/src/fulfillment/workflows/create-service-zones.ts @@ -0,0 +1,15 @@ +import { FulfillmentWorkflow } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createServiceZonesStep } from "../steps" + +export const createServiceZonesWorkflowId = "create-service-zones-workflow" +export const createServiceZonesWorkflow = createWorkflow( + createServiceZonesWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + const serviceZones = createServiceZonesStep(input.data) + + return serviceZones + } +) diff --git a/packages/core-flows/src/fulfillment/workflows/index.ts b/packages/core-flows/src/fulfillment/workflows/index.ts index c6cf4d5b8d..fc7da77d6b 100644 --- a/packages/core-flows/src/fulfillment/workflows/index.ts +++ b/packages/core-flows/src/fulfillment/workflows/index.ts @@ -1,3 +1,4 @@ export * from "./add-rules-to-fulfillment-shipping-option" -export * from "./remove-rules-from-fulfillment-shipping-option" +export * from "./create-service-zones" export * from "./create-shipping-options" +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 index c9c539b140..bebbb21621 100644 --- 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 @@ -1,7 +1,6 @@ -import { StepResponse, createStep } from "@medusajs/workflows-sdk" - import { Modules } from "@medusajs/modules-sdk" import { ContainerRegistrationKeys } from "@medusajs/utils" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" interface StepInput { input: { diff --git a/packages/medusa/src/api-v2/admin/fulfillment-sets/[id]/service-zones/route.ts b/packages/medusa/src/api-v2/admin/fulfillment-sets/[id]/service-zones/route.ts new file mode 100644 index 0000000000..a7f21087c8 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/fulfillment-sets/[id]/service-zones/route.ts @@ -0,0 +1,45 @@ +import { createServiceZonesWorkflow } from "@medusajs/core-flows" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" +import { AdminCreateFulfillmentSetServiceZonesType } from "../../validators" + +export const POST = async ( + req: MedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const workflowInput = { + data: [ + { + fulfillment_set_id: req.params.id, + name: req.validatedBody.name, + geo_zones: req.validatedBody.geo_zones, + }, + ], + } + + const { errors } = await createServiceZonesWorkflow(req.scope).run({ + input: workflowInput, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const [fulfillment_set] = await remoteQuery( + remoteQueryObjectFromString({ + entryPoint: "fulfillment_sets", + variables: { + id: req.params.id, + }, + fields: req.remoteQueryConfig.fields, + }) + ) + + res.status(200).json({ fulfillment_set }) +} diff --git a/packages/medusa/src/api-v2/admin/fulfillment-sets/middlewares.ts b/packages/medusa/src/api-v2/admin/fulfillment-sets/middlewares.ts new file mode 100644 index 0000000000..0f1d6e9298 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/fulfillment-sets/middlewares.ts @@ -0,0 +1,28 @@ +import { MiddlewareRoute } from "../../../types/middlewares" +import { authenticate } from "../../../utils/authenticate-middleware" +import { validateAndTransformBody } from "../../utils/validate-body" +import { validateAndTransformQuery } from "../../utils/validate-query" +import * as QueryConfig from "./query-config" +import { + AdminCreateFulfillmentSetServiceZonesSchema, + AdminFulfillmentSetParams +} from "./validators" + +export const adminFulfillmentSetsRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: "ALL", + matcher: "/admin/fulfillment-sets*", + middlewares: [authenticate("admin", ["session", "bearer", "api-key"])], + }, + { + method: ["POST"], + matcher: "/admin/fulfillment-sets/:id/service-zones", + middlewares: [ + validateAndTransformBody(AdminCreateFulfillmentSetServiceZonesSchema), + validateAndTransformQuery( + AdminFulfillmentSetParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, +] diff --git a/packages/medusa/src/api-v2/admin/fulfillment-sets/query-config.ts b/packages/medusa/src/api-v2/admin/fulfillment-sets/query-config.ts new file mode 100644 index 0000000000..fa9bf7bdb4 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/fulfillment-sets/query-config.ts @@ -0,0 +1,20 @@ +export const defaultAdminFulfillmentSetsFields = [ + "id", + "name", + "type", + "created_at", + "updated_at", + "deleted_at", + "*service_zones", + "*service_zones.geo_zones", +] + +export const retrieveTransformQueryConfig = { + defaults: defaultAdminFulfillmentSetsFields, + isList: false, +} + +export const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + isList: true, +} diff --git a/packages/medusa/src/api-v2/admin/fulfillment-sets/validators.ts b/packages/medusa/src/api-v2/admin/fulfillment-sets/validators.ts new file mode 100644 index 0000000000..f142519a4b --- /dev/null +++ b/packages/medusa/src/api-v2/admin/fulfillment-sets/validators.ts @@ -0,0 +1,73 @@ +import { z } from "zod" +import { createFindParams, createOperatorMap } from "../../utils/validators" + +const geoZoneBaseSchema = z.object({ + country_code: z.string(), + metadata: z.record(z.unknown()).optional(), +}) + +const geoZoneCountrySchema = geoZoneBaseSchema.merge( + z.object({ + type: z.literal("country"), + }) +) + +const geoZoneProvinceSchema = geoZoneBaseSchema.merge( + z.object({ + type: z.literal("province"), + province_code: z.string(), + }) +) + +const geoZoneCitySchema = geoZoneBaseSchema.merge( + z.object({ + type: z.literal("city"), + province_code: z.string(), + city: z.string(), + }) +) + +const geoZoneZipSchema = geoZoneBaseSchema.merge( + z.object({ + type: z.literal("zip"), + province_code: z.string(), + city: z.string(), + postal_expression: z.record(z.unknown()), + }) +) + +export const AdminCreateFulfillmentSetServiceZonesSchema = z + .object({ + name: z.string(), + geo_zones: z.array( + z.union([ + geoZoneCountrySchema, + geoZoneProvinceSchema, + geoZoneCitySchema, + geoZoneZipSchema, + ]) + ), + }) + .strict() + +export type AdminCreateFulfillmentSetServiceZonesType = z.infer< + typeof AdminCreateFulfillmentSetServiceZonesSchema +> + +export type AdminFulfillmentSetParamsType = z.infer< + typeof AdminFulfillmentSetParams +> +export const AdminFulfillmentSetParams = createFindParams({ + limit: 20, + offset: 0, +}).merge( + z.object({ + id: z.union([z.string(), z.array(z.string())]).optional(), + name: z.union([z.string(), z.array(z.string())]).optional(), + type: z.union([z.string(), z.array(z.string())]).optional(), + service_zone_id: z.union([z.string(), z.array(z.string())]).optional(), + created_at: createOperatorMap().optional(), + updated_at: createOperatorMap().optional(), + deleted_at: createOperatorMap().optional(), + }) +) 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 1e53c23a65..82c91ac2f2 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,9 @@ -import * as QueryConfig from "./query-config" - import { transformBody, transformQuery } from "../../../api/middlewares" +import { MiddlewareRoute } from "../../../types/middlewares" +import { authenticate } from "../../../utils/authenticate-middleware" +import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter" +import { validateAndTransformBody } from "../../utils/validate-body" +import * as QueryConfig from "./query-config" import { AdminCreateStockLocationFulfillmentSet, AdminGetStockLocationsLocationParams, @@ -12,11 +15,6 @@ import { AdminStockLocationsLocationSalesChannelBatchReq, } from "./validators" -import { MiddlewareRoute } from "../../../types/middlewares" -import { authenticate } from "../../../utils/authenticate-middleware" -import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter" -import { validateAndTransformBody } from "../../utils/validate-body" - export const adminStockLocationRoutesMiddlewares: MiddlewareRoute[] = [ { method: "ALL", diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index a5c69ff688..cdf5c7ffff 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -5,18 +5,19 @@ import { adminCollectionRoutesMiddlewares } from "./admin/collections/middleware import { adminCurrencyRoutesMiddlewares } from "./admin/currencies/middlewares" import { adminCustomerGroupRoutesMiddlewares } from "./admin/customer-groups/middlewares" import { adminCustomerRoutesMiddlewares } from "./admin/customers/middlewares" -import { adminShippingOptionRoutesMiddlewares } from "./admin/shipping-options/middlewares" import { adminDraftOrderRoutesMiddlewares } from "./admin/draft-orders/middlewares" +import { adminFulfillmentSetsRoutesMiddlewares } from "./admin/fulfillment-sets/middlewares" import { adminInventoryRoutesMiddlewares } from "./admin/inventory-items/middlewares" import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares" import { adminPaymentRoutesMiddlewares } from "./admin/payments/middlewares" import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middlewares" import { adminPricingRoutesMiddlewares } from "./admin/pricing/middlewares" -import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares" +import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" import { adminRegionRoutesMiddlewares } from "./admin/regions/middlewares" import { adminSalesChannelRoutesMiddlewares } from "./admin/sales-channels/middlewares" +import { adminShippingOptionRoutesMiddlewares } from "./admin/shipping-options/middlewares" import { adminStockLocationRoutesMiddlewares } from "./admin/stock-locations/middlewares" import { adminStoreRoutesMiddlewares } from "./admin/stores/middlewares" import { adminTaxRateRoutesMiddlewares } from "./admin/tax-rates/middlewares" @@ -65,5 +66,6 @@ export const config: MiddlewaresConfig = { ...adminStockLocationRoutesMiddlewares, ...adminProductTypeRoutesMiddlewares, ...adminUploadRoutesMiddlewares, + ...adminFulfillmentSetsRoutesMiddlewares, ], } diff --git a/packages/types/src/workflow/fulfillment/create-service-zones.ts b/packages/types/src/workflow/fulfillment/create-service-zones.ts new file mode 100644 index 0000000000..100e815a82 --- /dev/null +++ b/packages/types/src/workflow/fulfillment/create-service-zones.ts @@ -0,0 +1,21 @@ +import { + CreateCityGeoZoneDTO, + CreateCountryGeoZoneDTO, + CreateProvinceGeoZoneDTO, + CreateZipGeoZoneDTO, +} from "../../fulfillment" + +interface CreateServiceZone { + name: string + fulfillment_set_id: string + geo_zones?: ( + | Omit + | Omit + | Omit + | Omit + )[] +} + +export interface CreateServiceZonesWorkflowInput { + data: CreateServiceZone[] +} diff --git a/packages/types/src/workflow/fulfillment/index.ts b/packages/types/src/workflow/fulfillment/index.ts index ba97ae27d7..681e6f4ef0 100644 --- a/packages/types/src/workflow/fulfillment/index.ts +++ b/packages/types/src/workflow/fulfillment/index.ts @@ -1 +1,2 @@ +export * from "./create-service-zones" export * from "./create-shipping-options"