diff --git a/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts b/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts index 5aed39eeaacd5..a4c18fe493081 100644 --- a/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts +++ b/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts @@ -80,4 +80,76 @@ describe("POST /admin/customers/:id/addresses", () => { expect(customerWithAddresses.addresses?.length).toEqual(1) }) + + it("sets new shipping address as default and unsets the old one", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + addresses: [ + { + first_name: "John", + last_name: "Doe", + address_1: "Test street 1", + is_default_shipping: true, + }, + ], + }) + + const api = useApi() as any + const response = await api.post( + `/admin/customers/${customer.id}/addresses`, + { + first_name: "John", + last_name: "Doe", + address_1: "Test street 2", + is_default_shipping: true, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const [address] = await customerModuleService.listAddresses({ + customer_id: customer.id, + is_default_shipping: true, + }) + + expect(address.address_1).toEqual("Test street 2") + }) + + it("sets new billing address as default and unsets the old one", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + addresses: [ + { + first_name: "John", + last_name: "Doe", + address_1: "Test street 1", + is_default_billing: true, + }, + ], + }) + + const api = useApi() as any + const response = await api.post( + `/admin/customers/${customer.id}/addresses`, + { + first_name: "John", + last_name: "Doe", + address_1: "Test street 2", + is_default_billing: true, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const [address] = await customerModuleService.listAddresses({ + customer_id: customer.id, + is_default_billing: true, + }) + + expect(address.address_1).toEqual("Test street 2") + }) }) diff --git a/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts b/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts index 10e5d3b1d9d0d..b78c17a00226d 100644 --- a/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts +++ b/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts @@ -76,4 +76,89 @@ describe("POST /admin/customers/:id/addresses/:address_id", () => { }) ) }) + + it("updates a new address to be default and unsets old one", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + }) + const [, address] = await customerModuleService.addAddresses([ + { + customer_id: customer.id, + first_name: "John", + last_name: "Doe", + address_1: "Test street 1", + is_default_shipping: true, + }, + { + customer_id: customer.id, + first_name: "John", + last_name: "Doe", + address_1: "Test street 2", + }, + ]) + + const api = useApi() as any + const response = await api.post( + `/admin/customers/${customer.id}/addresses/${address.id}`, + { + first_name: "jane", + is_default_shipping: true, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const [defaultAddress] = await customerModuleService.listAddresses({ + customer_id: customer.id, + is_default_shipping: true, + }) + + expect(defaultAddress.first_name).toEqual("jane") + expect(defaultAddress.address_1).toEqual("Test street 2") + }) + + // do the same as above but for billing address + it("updates a new address to be default and unsets old one", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + }) + const [, address] = await customerModuleService.addAddresses([ + { + customer_id: customer.id, + first_name: "John", + last_name: "Doe", + address_1: "Test street 1", + is_default_billing: true, + }, + { + customer_id: customer.id, + first_name: "John", + last_name: "Doe", + address_1: "Test street 2", + }, + ]) + + const api = useApi() as any + const response = await api.post( + `/admin/customers/${customer.id}/addresses/${address.id}`, + { + first_name: "jane", + is_default_billing: true, + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const [defaultAddress] = await customerModuleService.listAddresses({ + customer_id: customer.id, + is_default_billing: true, + }) + + expect(defaultAddress.first_name).toEqual("jane") + expect(defaultAddress.address_1).toEqual("Test street 2") + }) }) diff --git a/packages/core-flows/src/customer/steps/index.ts b/packages/core-flows/src/customer/steps/index.ts index cf2fe8e9b0cac..00a01f8281ffc 100644 --- a/packages/core-flows/src/customer/steps/index.ts +++ b/packages/core-flows/src/customer/steps/index.ts @@ -4,3 +4,5 @@ export * from "./delete-customers" export * from "./create-addresses" export * from "./update-addresses" export * from "./delete-addresses" +export * from "./maybe-unset-default-billing-addresses" +export * from "./maybe-unset-default-shipping-addresses" diff --git a/packages/core-flows/src/customer/steps/maybe-unset-default-billing-addresses.ts b/packages/core-flows/src/customer/steps/maybe-unset-default-billing-addresses.ts new file mode 100644 index 0000000000000..50a2b36aca19d --- /dev/null +++ b/packages/core-flows/src/customer/steps/maybe-unset-default-billing-addresses.ts @@ -0,0 +1,61 @@ +import { + ICustomerModuleService, + CreateCustomerAddressDTO, + FilterableCustomerAddressProps, + CustomerAddressDTO, +} from "@medusajs/types" +import { createStep } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { unsetForUpdate, unsetForCreate } from "./utils" +import { isDefined } from "@medusajs/utils" + +type StepInput = { + create?: CreateCustomerAddressDTO[] + update?: { + selector: FilterableCustomerAddressProps + update: Partial + } +} + +export const maybeUnsetDefaultBillingAddressesStepId = + "maybe-unset-default-billing-customer-addresses" +export const maybeUnsetDefaultBillingAddressesStep = createStep( + maybeUnsetDefaultBillingAddressesStepId, + async (data: StepInput, { container }) => { + const customerModuleService = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + if (isDefined(data.create)) { + return unsetForCreate( + data.create, + customerModuleService, + "is_default_billing" + ) + } + + if (isDefined(data.update)) { + return unsetForUpdate( + data.update, + customerModuleService, + "is_default_billing" + ) + } + + throw new Error("Invalid step input") + }, + async (addressesToSet, { container }) => { + if (!addressesToSet?.length) { + return + } + + const customerModuleService = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await customerModuleService.updateAddress( + { id: addressesToSet }, + { is_default_billing: true } + ) + } +) diff --git a/packages/core-flows/src/customer/steps/maybe-unset-default-shipping-addresses.ts b/packages/core-flows/src/customer/steps/maybe-unset-default-shipping-addresses.ts new file mode 100644 index 0000000000000..7ffbaf41b0e16 --- /dev/null +++ b/packages/core-flows/src/customer/steps/maybe-unset-default-shipping-addresses.ts @@ -0,0 +1,60 @@ +import { + ICustomerModuleService, + CreateCustomerAddressDTO, + FilterableCustomerAddressProps, + CustomerAddressDTO, +} from "@medusajs/types" +import { createStep } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { unsetForUpdate, unsetForCreate } from "./utils" +import { isDefined } from "@medusajs/utils" + +type StepInput = { + create?: CreateCustomerAddressDTO[] + update?: { + selector: FilterableCustomerAddressProps + update: Partial + } +} + +export const maybeUnsetDefaultShippingAddressesStepId = + "maybe-unset-default-shipping-customer-addresses" +export const maybeUnsetDefaultShippingAddressesStep = createStep( + maybeUnsetDefaultShippingAddressesStepId, + async (data: StepInput, { container }) => { + const customerModuleService = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + if (isDefined(data.create)) { + return unsetForCreate( + data.create, + customerModuleService, + "is_default_shipping" + ) + } + + if (isDefined(data.update)) { + return unsetForUpdate( + data.update, + customerModuleService, + "is_default_shipping" + ) + } + + throw new Error("Invalid step input") + }, + async (addressesToSet, { container }) => { + if (!addressesToSet?.length) { + return + } + + const customerModuleService = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await customerModuleService.updateAddress( + { id: addressesToSet }, + { is_default_shipping: true } + ) + } +) diff --git a/packages/core-flows/src/customer/steps/utils/index.ts b/packages/core-flows/src/customer/steps/utils/index.ts new file mode 100644 index 0000000000000..933792bc53256 --- /dev/null +++ b/packages/core-flows/src/customer/steps/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./unset-address-for-create" +export * from "./unset-address-for-update" diff --git a/packages/core-flows/src/customer/steps/utils/unset-address-for-create.ts b/packages/core-flows/src/customer/steps/utils/unset-address-for-create.ts new file mode 100644 index 0000000000000..25c6e15df2479 --- /dev/null +++ b/packages/core-flows/src/customer/steps/utils/unset-address-for-create.ts @@ -0,0 +1,33 @@ +import { + CreateCustomerAddressDTO, + ICustomerModuleService, +} from "@medusajs/types" +import { StepResponse } from "@medusajs/workflows-sdk" + +export const unsetForCreate = async ( + data: CreateCustomerAddressDTO[], + customerService: ICustomerModuleService, + field: "is_default_billing" | "is_default_shipping" +) => { + const customerIds = data.reduce((acc, curr) => { + if (curr[field]) { + acc.push(curr.customer_id) + } + return acc + }, []) + + const customerDefaultAddresses = await customerService.listAddresses({ + customer_id: customerIds, + [field]: true, + }) + + await customerService.updateAddress( + { customer_id: customerIds, [field]: true }, + { [field]: false } + ) + + return new StepResponse( + void 0, + customerDefaultAddresses.map((address) => address.id) + ) +} diff --git a/packages/core-flows/src/customer/steps/utils/unset-address-for-update.ts b/packages/core-flows/src/customer/steps/utils/unset-address-for-update.ts new file mode 100644 index 0000000000000..aa12bb2122f2b --- /dev/null +++ b/packages/core-flows/src/customer/steps/utils/unset-address-for-update.ts @@ -0,0 +1,40 @@ +import { + CustomerAddressDTO, + FilterableCustomerAddressProps, + ICustomerModuleService, +} from "@medusajs/types" +import { StepResponse } from "@medusajs/workflows-sdk" + +export const unsetForUpdate = async ( + data: { + selector: FilterableCustomerAddressProps + update: Partial + }, + customerService: ICustomerModuleService, + field: "is_default_billing" | "is_default_shipping" +) => { + if (!data.update[field]) { + return new StepResponse(void 0) + } + + const affectedCustomers = await customerService.listAddresses(data.selector, { + select: ["id", "customer_id"], + }) + + const customerIds = affectedCustomers.map((address) => address.customer_id) + + const customerDefaultAddresses = await customerService.listAddresses({ + customer_id: customerIds, + [field]: true, + }) + + await customerService.updateAddress( + { customer_id: customerIds, [field]: true }, + { [field]: false } + ) + + return new StepResponse( + void 0, + customerDefaultAddresses.map((address) => address.id) + ) +} diff --git a/packages/core-flows/src/customer/workflows/create-addresses.ts b/packages/core-flows/src/customer/workflows/create-addresses.ts index 12bc2d2ff9db7..ea08335498aa0 100644 --- a/packages/core-flows/src/customer/workflows/create-addresses.ts +++ b/packages/core-flows/src/customer/workflows/create-addresses.ts @@ -1,6 +1,15 @@ import { CreateCustomerAddressDTO, CustomerAddressDTO } from "@medusajs/types" -import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" -import { createCustomerAddressesStep } from "../steps" +import { + WorkflowData, + createWorkflow, + parallelize, + transform, +} from "@medusajs/workflows-sdk" +import { + createCustomerAddressesStep, + maybeUnsetDefaultBillingAddressesStep, + maybeUnsetDefaultShippingAddressesStep, +} from "../steps" type WorkflowInput = { addresses: CreateCustomerAddressDTO[] } @@ -8,6 +17,15 @@ export const createCustomerAddressesWorkflowId = "create-customer-addresses" export const createCustomerAddressesWorkflow = createWorkflow( createCustomerAddressesWorkflowId, (input: WorkflowData): WorkflowData => { + const unsetInput = transform(input, (data) => ({ + create: data.addresses, + })) + + parallelize( + maybeUnsetDefaultShippingAddressesStep(unsetInput), + maybeUnsetDefaultBillingAddressesStep(unsetInput) + ) + return createCustomerAddressesStep(input.addresses) } ) diff --git a/packages/core-flows/src/customer/workflows/update-addresses.ts b/packages/core-flows/src/customer/workflows/update-addresses.ts index fa8aff557c292..3d9015cb011f1 100644 --- a/packages/core-flows/src/customer/workflows/update-addresses.ts +++ b/packages/core-flows/src/customer/workflows/update-addresses.ts @@ -2,8 +2,17 @@ import { FilterableCustomerAddressProps, CustomerAddressDTO, } from "@medusajs/types" -import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" -import { updateCustomerAddressesStep } from "../steps" +import { + WorkflowData, + createWorkflow, + parallelize, + transform, +} from "@medusajs/workflows-sdk" +import { + maybeUnsetDefaultBillingAddressesStep, + maybeUnsetDefaultShippingAddressesStep, + updateCustomerAddressesStep, +} from "../steps" type WorkflowInput = { selector: FilterableCustomerAddressProps @@ -14,6 +23,15 @@ export const updateCustomerAddressesWorkflowId = "update-customer-addresses" export const updateCustomerAddressesWorkflow = createWorkflow( updateCustomerAddressesWorkflowId, (input: WorkflowData): WorkflowData => { + const unsetInput = transform(input, (data) => ({ + update: data, + })) + + parallelize( + maybeUnsetDefaultShippingAddressesStep(unsetInput), + maybeUnsetDefaultBillingAddressesStep(unsetInput) + ) + return updateCustomerAddressesStep(input) } ) diff --git a/packages/customer/integration-tests/__tests__/services/customer-module/index.spec.ts b/packages/customer/integration-tests/__tests__/services/customer-module/index.spec.ts index 7cde20eed819d..26beb2c4abf66 100644 --- a/packages/customer/integration-tests/__tests__/services/customer-module/index.spec.ts +++ b/packages/customer/integration-tests/__tests__/services/customer-module/index.spec.ts @@ -100,6 +100,37 @@ describe("Customer Module Service", () => { ) }) + it("should fail to create two default shipping", async () => { + const customerData = { + company_name: "Acme Corp", + first_name: "John", + last_name: "Doe", + addresses: [ + { + address_1: "Testvej 1", + address_2: "Testvej 2", + city: "Testby", + country_code: "DK", + province: "Test", + postal_code: "8000", + phone: "123456789", + metadata: { membership: "gold" }, + is_default_shipping: true, + }, + { + address_1: "Test Ave 1", + address_2: "Test Ave 2", + city: "Testville", + country_code: "US", + is_default_shipping: true, + }, + ], + } + await expect(service.create(customerData)).rejects.toThrow( + "A default shipping address already exists" + ) + }) + it("should create multiple customers", async () => { const customersData = [ { @@ -662,7 +693,7 @@ describe("Customer Module Service", () => { country_code: "US", is_default_shipping: true, }) - ).rejects.toThrow() + ).rejects.toThrow("A default shipping address already exists") }) it("should only be possible to add one default billing address per customer", async () => { @@ -696,7 +727,7 @@ describe("Customer Module Service", () => { country_code: "US", is_default_billing: true, }) - ).rejects.toThrow() + ).rejects.toThrow("A default billing address already exists") }) }) @@ -813,6 +844,29 @@ describe("Customer Module Service", () => { ]) ) }) + + it("should fail when updating address to a default shipping address when one already exists", async () => { + const customer = await service.create({ + first_name: "John", + last_name: "Doe", + addresses: [ + { + address_name: "Home", + address_1: "123 Main St", + is_default_shipping: true, + }, + ], + }) + const address = await service.addAddresses({ + customer_id: customer.id, + address_name: "Work", + address_1: "456 Main St", + }) + + await expect( + service.updateAddress(address.id, { is_default_shipping: true }) + ).rejects.toThrow("A default shipping address already exists") + }) }) describe("listAddresses", () => { diff --git a/packages/customer/src/services/customer-module.ts b/packages/customer/src/services/customer-module.ts index 542b618c57cc1..7a41d481b84f9 100644 --- a/packages/customer/src/services/customer-module.ts +++ b/packages/customer/src/services/customer-module.ts @@ -8,7 +8,6 @@ import { CustomerTypes, SoftDeleteReturn, RestoreReturn, - CustomerUpdatableFields, } from "@medusajs/types" import { @@ -18,9 +17,17 @@ import { mapObjectTo, isString, isObject, + isDuplicateError, } from "@medusajs/utils" import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import * as services from "../services" +import { MedusaError } from "@medusajs/utils" +import { EntityManager } from "@mikro-orm/core" + +const UNIQUE_CUSTOMER_SHIPPING_ADDRESS = + "IDX_customer_address_unique_customer_shipping" +const UNIQUE_CUSTOMER_BILLING_ADDRESS = + "IDX_customer_address_unique_customer_billing" type InjectedDependencies = { baseRepository: DAL.RepositoryService @@ -97,15 +104,10 @@ export default class CustomerModuleService implements ICustomerModuleService { ) { const data = Array.isArray(dataOrArray) ? dataOrArray : [dataOrArray] - // keep address data for creation - const addressData = data.map((d) => d.addresses) - const customers = await this.customerService_.create(data, sharedContext) - // decorate addresses with customer ids - // filter out addresses without data - const addressDataWithCustomerIds = addressData - .map((addresses, i) => { + const addressDataWithCustomerIds = data + .map(({ addresses }, i) => { if (!addresses) { return [] } @@ -117,7 +119,7 @@ export default class CustomerModuleService implements ICustomerModuleService { }) .flat() - await this.addressService_.create(addressDataWithCustomerIds, sharedContext) + await this.addAddresses(addressDataWithCustomerIds, sharedContext) const serialized = await this.baseRepository_.serialize< CustomerTypes.CustomerDTO[] @@ -457,6 +459,8 @@ export default class CustomerModuleService implements ICustomerModuleService { sharedContext ) + await this.flush(sharedContext).catch(this.handleDbErrors) + const serialized = await this.baseRepository_.serialize< CustomerTypes.CustomerAddressDTO[] >(addresses, { populate: true }) @@ -522,6 +526,9 @@ export default class CustomerModuleService implements ICustomerModuleService { updateData, sharedContext ) + + await this.flush(sharedContext).catch(this.handleDbErrors) + const serialized = await this.baseRepository_.serialize< CustomerTypes.CustomerAddressDTO[] >(addresses, { populate: true }) @@ -775,4 +782,30 @@ export default class CustomerModuleService implements ICustomerModuleService { ) : void 0 } + + private async flush(context: Context) { + const em = (context.manager ?? context.transactionManager) as EntityManager + await em.flush() + } + + private async handleDbErrors(err: any) { + if (isDuplicateError(err)) { + switch (err.constraint) { + case UNIQUE_CUSTOMER_SHIPPING_ADDRESS: + throw new MedusaError( + MedusaError.Types.DUPLICATE_ERROR, + "A default shipping address already exists" + ) + case UNIQUE_CUSTOMER_BILLING_ADDRESS: + throw new MedusaError( + MedusaError.Types.DUPLICATE_ERROR, + "A default billing address already exists" + ) + default: + break + } + } + + throw err + } } diff --git a/packages/utils/src/exceptions/index.ts b/packages/utils/src/exceptions/index.ts new file mode 100644 index 0000000000000..0fb0a338463cc --- /dev/null +++ b/packages/utils/src/exceptions/index.ts @@ -0,0 +1,2 @@ +export * from "./postgres-error" +export * from "./is-duplicate-error" diff --git a/packages/utils/src/exceptions/is-duplicate-error.ts b/packages/utils/src/exceptions/is-duplicate-error.ts new file mode 100644 index 0000000000000..5e86ec06fcd8b --- /dev/null +++ b/packages/utils/src/exceptions/is-duplicate-error.ts @@ -0,0 +1,4 @@ +import { PostgresError } from "./postgres-error" +export const isDuplicateError = (err: Error & { code?: string }) => { + return err.code === PostgresError.DUPLICATE_ERROR +} diff --git a/packages/utils/src/exceptions/postgres-error.ts b/packages/utils/src/exceptions/postgres-error.ts new file mode 100644 index 0000000000000..046a9383989db --- /dev/null +++ b/packages/utils/src/exceptions/postgres-error.ts @@ -0,0 +1,6 @@ +export enum PostgresError { + DUPLICATE_ERROR = "23505", + FOREIGN_KEY_ERROR = "23503", + SERIALIZATION_FAILURE = "40001", + NULL_VIOLATION = "23502", +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index c1ca69b9a3706..df223c851e648 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -13,3 +13,4 @@ export * from "./promotion" export * from "./search" export * from "./shipping" export * from "./orchestration" +export * from "./exceptions"