fix(customer): Unique constraint on customer email (#7439)
**What** Prevent creating multiple customers with the same email
This commit is contained in:
1 parent
066fd3c3d2
commit
77d72c5791
10 files changed
+181
-30
No files matched your search
@@ -7,6 +7,17 @@ import {
|
|||||||
} from "@mikro-orm/core"
|
} from "@mikro-orm/core"
|
||||||
import { MedusaError, upperCaseFirst } from "../../common"
|
import { MedusaError, upperCaseFirst } from "../../common"
|
||||||
|
|
||||||
|
function parseValue(value: string) {
|
||||||
|
switch (value) {
|
||||||
|
case "t":
|
||||||
|
return "true"
|
||||||
|
case "f":
|
||||||
|
return "false"
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const dbErrorMapper = (err: Error) => {
|
export const dbErrorMapper = (err: Error) => {
|
||||||
if (err instanceof NotFoundError) {
|
if (err instanceof NotFoundError) {
|
||||||
throw new MedusaError(MedusaError.Types.NOT_FOUND, err.message)
|
throw new MedusaError(MedusaError.Types.NOT_FOUND, err.message)
|
||||||
@@ -24,8 +35,8 @@ export const dbErrorMapper = (err: Error) => {
|
|||||||
throw new MedusaError(
|
throw new MedusaError(
|
||||||
MedusaError.Types.INVALID_DATA,
|
MedusaError.Types.INVALID_DATA,
|
||||||
`${upperCaseFirst(info.table.split("_").join(" "))} with ${info.keys
|
`${upperCaseFirst(info.table.split("_").join(" "))} with ${info.keys
|
||||||
.map((key, i) => `${key}: ${info.values[i]}`)
|
.map((key, i) => `${key}: ${parseValue(info.values[i])}`)
|
||||||
.join(", ")} already exists.`
|
.join(", ")}, already exists.`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -864,7 +864,7 @@ describe("mikroOrmRepository", () => {
|
|||||||
.upsertWithReplace([entity3])
|
.upsertWithReplace([entity3])
|
||||||
.catch((e) => e.message)
|
.catch((e) => e.message)
|
||||||
|
|
||||||
expect(err).toEqual("Entity3 with title: en3 already exists.")
|
expect(err).toEqual("Entity3 with title: en3, already exists.")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should map NotNullConstraintViolationException MedusaError on upsertWithReplace", async () => {
|
it("should map NotNullConstraintViolationException MedusaError on upsertWithReplace", async () => {
|
||||||
|
|||||||
+87
@@ -38,6 +38,93 @@ moduleIntegrationTestRunner({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should create two customers with the same email but one has an account", async () => {
|
||||||
|
const customerData = {
|
||||||
|
company_name: "Acme Corp",
|
||||||
|
first_name: "John",
|
||||||
|
last_name: "Doe",
|
||||||
|
email: "john.doe@acmecorp.com",
|
||||||
|
phone: "123456789",
|
||||||
|
created_by: "admin",
|
||||||
|
metadata: { membership: "gold" },
|
||||||
|
}
|
||||||
|
const customerData2 = {
|
||||||
|
...customerData,
|
||||||
|
has_account: true,
|
||||||
|
}
|
||||||
|
const [customer, customer2] = await service.create([
|
||||||
|
customerData,
|
||||||
|
customerData2,
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(customer).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: expect.any(String),
|
||||||
|
company_name: "Acme Corp",
|
||||||
|
first_name: "John",
|
||||||
|
last_name: "Doe",
|
||||||
|
email: "john.doe@acmecorp.com",
|
||||||
|
phone: "123456789",
|
||||||
|
created_by: "admin",
|
||||||
|
metadata: expect.objectContaining({ membership: "gold" }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
expect(customer2).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: expect.any(String),
|
||||||
|
company_name: "Acme Corp",
|
||||||
|
first_name: "John",
|
||||||
|
last_name: "Doe",
|
||||||
|
email: "john.doe@acmecorp.com",
|
||||||
|
phone: "123456789",
|
||||||
|
created_by: "admin",
|
||||||
|
metadata: expect.objectContaining({ membership: "gold" }),
|
||||||
|
has_account: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should fail to create a duplicated guest customers", async () => {
|
||||||
|
const customerData = {
|
||||||
|
company_name: "Acme Corp",
|
||||||
|
first_name: "John",
|
||||||
|
last_name: "Doe",
|
||||||
|
email: "john.doe@acmecorp.com",
|
||||||
|
phone: "123456789",
|
||||||
|
created_by: "admin",
|
||||||
|
metadata: { membership: "gold" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = await service
|
||||||
|
.create([customerData, customerData])
|
||||||
|
.catch((err) => err)
|
||||||
|
|
||||||
|
expect(err.message).toBe(
|
||||||
|
"Customer with email: john.doe@acmecorp.com, has_account: false, already exists."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should fail to create a duplicated customers", async () => {
|
||||||
|
const customerData = {
|
||||||
|
company_name: "Acme Corp",
|
||||||
|
first_name: "John",
|
||||||
|
last_name: "Doe",
|
||||||
|
email: "john.doe@acmecorp.com",
|
||||||
|
phone: "123456789",
|
||||||
|
created_by: "admin",
|
||||||
|
metadata: { membership: "gold" },
|
||||||
|
has_account: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = await service
|
||||||
|
.create([customerData, customerData])
|
||||||
|
.catch((err) => err)
|
||||||
|
|
||||||
|
expect(err.message).toBe(
|
||||||
|
"Customer with email: john.doe@acmecorp.com, has_account: true, already exists."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should create address", async () => {
|
it("should create address", async () => {
|
||||||
const customerData = {
|
const customerData = {
|
||||||
company_name: "Acme Corp",
|
company_name: "Acme Corp",
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import * as entities from "./src/models"
|
import * as entities from "./src/models"
|
||||||
|
import { TSMigrationGenerator } from "@medusajs/utils"
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entities: Object.values(entities),
|
entities: Object.values(entities),
|
||||||
schema: "public",
|
schema: "public",
|
||||||
clientUrl: "postgres://postgres@localhost/medusa-customer",
|
clientUrl: "postgres://postgres@localhost/medusa-customer",
|
||||||
type: "postgresql",
|
type: "postgresql",
|
||||||
|
migrations: {
|
||||||
|
generator: TSMigrationGenerator,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -124,6 +124,14 @@
|
|||||||
"name": "customer",
|
"name": "customer",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"indexes": [
|
"indexes": [
|
||||||
|
{
|
||||||
|
"keyName": "IDX_customer_email_has_account_unique",
|
||||||
|
"columnNames": [],
|
||||||
|
"composite": false,
|
||||||
|
"primary": false,
|
||||||
|
"unique": false,
|
||||||
|
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_customer_email_has_account_unique\" ON \"customer\" (email, has_account) WHERE deleted_at IS NULL"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"keyName": "customer_pkey",
|
"keyName": "customer_pkey",
|
||||||
"columnNames": [
|
"columnNames": [
|
||||||
@@ -321,20 +329,20 @@
|
|||||||
"unique": false
|
"unique": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keyName": "IDX_customer_address_unqiue_customer_billing",
|
"keyName": "IDX_customer_address_unique_customer_billing",
|
||||||
"columnNames": [],
|
"columnNames": [],
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"primary": false,
|
"primary": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"expression": "create unique index \"IDX_customer_address_unqiue_customer_billing\" on \"customer_address\" (\"customer_id\") where \"is_default_billing\" = true"
|
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_customer_address_unique_customer_billing\" ON \"customer_address\" (customer_id) WHERE \"is_default_billing\" = true"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keyName": "IDX_customer_address_unqiue_customer_shipping",
|
"keyName": "IDX_customer_address_unique_customer_shipping",
|
||||||
"columnNames": [],
|
"columnNames": [],
|
||||||
"composite": false,
|
"composite": false,
|
||||||
"primary": false,
|
"primary": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"expression": "create unique index \"IDX_customer_address_unique_customer_shipping\" on \"customer_address\" (\"customer_id\") where \"is_default_shipping\" = true"
|
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_customer_address_unique_customer_shipping\" ON \"customer_address\" (customer_id) WHERE \"is_default_shipping\" = true"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keyName": "customer_address_pkey",
|
"keyName": "customer_address_pkey",
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Migration } from '@mikro-orm/migrations';
|
||||||
|
|
||||||
|
export class Migration20240524123112 extends Migration {
|
||||||
|
async up(): Promise<void> {
|
||||||
|
this.addSql('CREATE UNIQUE INDEX IF NOT EXISTS "IDX_customer_email_has_account_unique" ON "customer" (email, has_account) WHERE deleted_at IS NULL;');
|
||||||
|
|
||||||
|
this.addSql('drop index if exists "IDX_customer_address_unqiue_customer_billing";');
|
||||||
|
this.addSql('drop index if exists "IDX_customer_address_unqiue_customer_shipping";');
|
||||||
|
this.addSql('CREATE UNIQUE INDEX IF NOT EXISTS "IDX_customer_address_unique_customer_billing" ON "customer_address" (customer_id) WHERE "is_default_billing" = true;');
|
||||||
|
this.addSql('CREATE UNIQUE INDEX IF NOT EXISTS "IDX_customer_address_unique_customer_shipping" ON "customer_address" (customer_id) WHERE "is_default_shipping" = true;');
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(): Promise<void> {
|
||||||
|
this.addSql('drop index if exists "IDX_customer_email_has_account_unique";');
|
||||||
|
|
||||||
|
this.addSql('drop index if exists "IDX_customer_address_unique_customer_billing";');
|
||||||
|
this.addSql('drop index if exists "IDX_customer_address_unique_customer_shipping";');
|
||||||
|
this.addSql('create unique index if not exists "IDX_customer_address_unqiue_customer_billing" on "customer_address" ("customer_id") where "is_default_billing" = true;');
|
||||||
|
this.addSql('create unique index if not exists "IDX_customer_address_unique_customer_shipping" on "customer_address" ("customer_id") where "is_default_shipping" = true;');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { DAL } from "@medusajs/types"
|
import { DAL } from "@medusajs/types"
|
||||||
import { Searchable, generateEntityId } from "@medusajs/utils"
|
import {
|
||||||
|
createPsqlIndexStatementHelper,
|
||||||
|
generateEntityId,
|
||||||
|
Searchable,
|
||||||
|
} from "@medusajs/utils"
|
||||||
import {
|
import {
|
||||||
BeforeCreate,
|
BeforeCreate,
|
||||||
Cascade,
|
Cascade,
|
||||||
Entity,
|
Entity,
|
||||||
Index,
|
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OnInit,
|
OnInit,
|
||||||
OptionalProps,
|
OptionalProps,
|
||||||
@@ -15,22 +18,27 @@ import Customer from "./customer"
|
|||||||
|
|
||||||
type OptionalAddressProps = DAL.EntityDateColumns // TODO: To be revisited when more clear
|
type OptionalAddressProps = DAL.EntityDateColumns // TODO: To be revisited when more clear
|
||||||
|
|
||||||
export const UNIQUE_CUSTOMER_SHIPPING_ADDRESS =
|
const CustomerAddressUniqueCustomerShippingAddress =
|
||||||
"IDX_customer_address_unique_customer_shipping"
|
createPsqlIndexStatementHelper({
|
||||||
export const UNIQUE_CUSTOMER_BILLING_ADDRESS =
|
name: "IDX_customer_address_unique_customer_shipping",
|
||||||
"IDX_customer_address_unique_customer_billing"
|
tableName: "customer_address",
|
||||||
|
columns: "customer_id",
|
||||||
|
unique: true,
|
||||||
|
where: '"is_default_shipping" = true',
|
||||||
|
})
|
||||||
|
|
||||||
|
const CustomerAddressUniqueCustomerBillingAddress =
|
||||||
|
createPsqlIndexStatementHelper({
|
||||||
|
name: "IDX_customer_address_unique_customer_billing",
|
||||||
|
tableName: "customer_address",
|
||||||
|
columns: "customer_id",
|
||||||
|
unique: true,
|
||||||
|
where: '"is_default_billing" = true',
|
||||||
|
})
|
||||||
|
|
||||||
@Entity({ tableName: "customer_address" })
|
@Entity({ tableName: "customer_address" })
|
||||||
@Index({
|
@CustomerAddressUniqueCustomerShippingAddress.MikroORMIndex()
|
||||||
name: UNIQUE_CUSTOMER_SHIPPING_ADDRESS,
|
@CustomerAddressUniqueCustomerBillingAddress.MikroORMIndex()
|
||||||
expression:
|
|
||||||
'create unique index "IDX_customer_address_unique_customer_shipping" on "customer_address" ("customer_id") where "is_default_shipping" = true',
|
|
||||||
})
|
|
||||||
@Index({
|
|
||||||
name: UNIQUE_CUSTOMER_BILLING_ADDRESS,
|
|
||||||
expression:
|
|
||||||
'create unique index "IDX_customer_address_unique_customer_billing" on "customer_address" ("customer_id") where "is_default_billing" = true',
|
|
||||||
})
|
|
||||||
export default class Address {
|
export default class Address {
|
||||||
[OptionalProps]: OptionalAddressProps
|
[OptionalProps]: OptionalAddressProps
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { DAL } from "@medusajs/types"
|
import { DAL } from "@medusajs/types"
|
||||||
import { DALUtils, Searchable, generateEntityId } from "@medusajs/utils"
|
import {
|
||||||
|
createPsqlIndexStatementHelper,
|
||||||
|
DALUtils,
|
||||||
|
generateEntityId,
|
||||||
|
Searchable,
|
||||||
|
} from "@medusajs/utils"
|
||||||
import {
|
import {
|
||||||
BeforeCreate,
|
BeforeCreate,
|
||||||
Cascade,
|
Cascade,
|
||||||
@@ -7,8 +12,8 @@ import {
|
|||||||
Entity,
|
Entity,
|
||||||
Filter,
|
Filter,
|
||||||
ManyToMany,
|
ManyToMany,
|
||||||
OnInit,
|
|
||||||
OneToMany,
|
OneToMany,
|
||||||
|
OnInit,
|
||||||
OptionalProps,
|
OptionalProps,
|
||||||
PrimaryKey,
|
PrimaryKey,
|
||||||
Property,
|
Property,
|
||||||
@@ -22,8 +27,16 @@ type OptionalCustomerProps =
|
|||||||
| "addresses"
|
| "addresses"
|
||||||
| DAL.SoftDeletableEntityDateColumns
|
| DAL.SoftDeletableEntityDateColumns
|
||||||
|
|
||||||
|
const CustomerUniqueEmail = createPsqlIndexStatementHelper({
|
||||||
|
tableName: "customer",
|
||||||
|
columns: ["email", "has_account"],
|
||||||
|
unique: true,
|
||||||
|
where: "deleted_at IS NULL",
|
||||||
|
})
|
||||||
|
|
||||||
@Entity({ tableName: "customer" })
|
@Entity({ tableName: "customer" })
|
||||||
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
|
@Filter(DALUtils.mikroOrmSoftDeletableFilterOptions)
|
||||||
|
@CustomerUniqueEmail.MikroORMIndex()
|
||||||
export default class Customer {
|
export default class Customer {
|
||||||
[OptionalProps]?: OptionalCustomerProps
|
[OptionalProps]?: OptionalCustomerProps
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { MikroOrmBaseRepository as BaseRepository } from "@medusajs/utils"
|
|
||||||
@@ -711,7 +711,7 @@ moduleIntegrationTestRunner({
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
).rejects.toThrowError(
|
).rejects.toThrowError(
|
||||||
/Tax rate rule with tax_rate_id: .*?, reference_id: product_id_1 already exists./
|
/Tax rate rule with tax_rate_id: .*?, reference_id: product_id_1, already exists./
|
||||||
)
|
)
|
||||||
|
|
||||||
const rate = await service.create({
|
const rate = await service.create({
|
||||||
@@ -729,7 +729,7 @@ moduleIntegrationTestRunner({
|
|||||||
reference_id: "product_id_1",
|
reference_id: "product_id_1",
|
||||||
})
|
})
|
||||||
).rejects.toThrowError(
|
).rejects.toThrowError(
|
||||||
/Tax rate rule with tax_rate_id: .*?, reference_id: product_id_1 already exists./
|
/Tax rate rule with tax_rate_id: .*?, reference_id: product_id_1, already exists./
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -764,7 +764,7 @@ moduleIntegrationTestRunner({
|
|||||||
province_code: "QC",
|
province_code: "QC",
|
||||||
})
|
})
|
||||||
).rejects.toThrowError(
|
).rejects.toThrowError(
|
||||||
"Tax region with country_code: ca, province_code: qc already exists."
|
"Tax region with country_code: ca, province_code: qc, already exists."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -797,7 +797,7 @@ moduleIntegrationTestRunner({
|
|||||||
is_default: true,
|
is_default: true,
|
||||||
})
|
})
|
||||||
).rejects.toThrowError(
|
).rejects.toThrowError(
|
||||||
/Tax rate with tax_region_id: .*? already exists./
|
/Tax rate with tax_region_id: .*?, already exists./
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user