fix(customer): Unique constraint on customer email (#7439)

**What**
Prevent creating multiple customers with the same email
This commit is contained in:
Adrien de Peretti authored and GitHub committed 2024-05-24 14:20:54 +00:00
1 parent 066fd3c3d2
commit 77d72c5791
11 files changed
+186 -35

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.`
) )
} }
@@ -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 () => {
@@ -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,
},
} }
+5 -5
View File
@@ -30,11 +30,11 @@
"build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json", "build": "rimraf dist && tsc --build && tsc-alias -p tsconfig.json",
"test": "jest --runInBand --bail --passWithNoTests --forceExit -- src", "test": "jest --runInBand --bail --passWithNoTests --forceExit -- src",
"test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts", "test:integration": "jest --forceExit -- integration-tests/**/__tests__/**/*.ts",
"migration:generate": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate", "migration:generate": "MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:generate",
"migration:initial": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial", "migration:initial": "MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create --initial",
"migration:create": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create", "migration:create": "MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:create",
"migration:up": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:up", "migration:up": "MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm migration:up",
"orm:cache:clear": " MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear" "orm:cache:clear": "MIKRO_ORM_CLI=./mikro-orm.config.dev.ts mikro-orm cache:clear"
}, },
"devDependencies": { "devDependencies": {
"@medusajs/types": "workspace:^", "@medusajs/types": "workspace:^",
@@ -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;');
}
}
+24 -16
View File
@@ -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./
) )
}) })