feat(medusa): Claim customer orders (#2710)

This commit is contained in:
Philip Korsholm
2022-12-08 17:48:49 +01:00
committed by GitHub
parent 86f9455d00
commit a6243618fe
36 changed files with 872 additions and 107 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/medusa": minor
---
Feat: allow customers to claim orders
BREAKING CHANGE: `customerService.retrieveByEmail` is being deprecated in favor of two methods: `customerService.retrieveRegisteredByEmail` and `customerService.retrieveUnRegisteredByEmail`. Please use `customerService.list({ email: <customer email> })` in the future
@@ -1,4 +1,3 @@
const jwt = require("jsonwebtoken")
const path = require("path")
const setupServer = require("../../../helpers/setup-server")
@@ -27,7 +27,7 @@ describe("[MEDUSA_FF_ORDER_EDITING] /admin/payment-collections", () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
const [process, connection] = await startServerWithEnvironment({
cwd,
env: { MEDUSA_FF_ORDER_EDITING: true }
env: { MEDUSA_FF_ORDER_EDITING: true },
})
dbConnection = connection
medusaProcess = process
@@ -1799,6 +1799,56 @@ describe("/store/carts", () => {
expect(res.data.cart.payment_authorized_at).not.toBe(null)
expect(res.data.cart.completed_at).not.toBe(null)
})
it("completes cart with a non-customer and for a customer with the same email created later the order doesn't show up", async () => {
const api = useApi()
const customerEmail = "test-email-for-non-existent-customer@test.com"
const product = await simpleProductFactory(dbConnection)
const region = await simpleRegionFactory(dbConnection, { tax_rate: 10 })
const cart = await simpleCartFactory(dbConnection, {
customer: {
email: customerEmail,
has_account: false,
},
region: region.id,
line_items: [
{
variant_id: product.variants[0].id,
quantity: 1,
unit_price: 1000,
},
],
})
await api.post(`/store/carts/${cart.id}/payment-sessions`)
const completeRes = await api.post(`/store/carts/${cart.id}/complete`)
expect(completeRes.status).toEqual(200)
const customerResponse = await api.post("/store/customers", {
first_name: "John",
last_name: "Doe",
email: customerEmail,
password: "test",
})
const [authCookie] = customerResponse.headers["set-cookie"][0].split(";")
const customerOrdersResponse = await api
.get("/store/customers/me/orders?status[]=completed", {
headers: {
Cookie: authCookie,
},
})
.catch((err) => {
return err.response
})
expect(customerOrdersResponse.status).toEqual(200)
expect(customerOrdersResponse.data.orders.length).toEqual(0)
})
})
describe("POST /store/carts/:id/shipping-methods", () => {
@@ -1,9 +1,11 @@
const jwt = require("jsonwebtoken")
const path = require("path")
const { Address, Customer, Order, Region } = require("@medusajs/medusa")
const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db")
const { simpleOrderFactory } = require("../../factories")
jest.setTimeout(30000)
@@ -28,6 +30,78 @@ describe("/store/customers", () => {
medusaProcess.kill()
})
describe("POST /store/customers/confirm-claim", () => {
let orderId
beforeEach(async () => {
const manager = dbConnection.manager
await manager.insert(Customer, {
id: "test_customer",
first_name: "John",
last_name: "Deere",
email: "john@deere.com",
password_hash:
"c2NyeXB0AAEAAAABAAAAAVMdaddoGjwU1TafDLLlBKnOTQga7P2dbrfgf3fB+rCD/cJOMuGzAvRdKutbYkVpuJWTU39P7OpuWNkUVoEETOVLMJafbI8qs8Qx/7jMQXkN", // password matching "test"
has_account: true,
})
await manager.insert(Customer, {
id: "test_customer-1",
first_name: "John",
last_name: "Deere",
email: "john@deere.com",
})
const order = await simpleOrderFactory(dbConnection, {
customer: {
id: "test_customer-1",
},
})
orderId = order.id
})
afterEach(async () => {
await doAfterEach()
})
it("Successfully confirms a claim ", async () => {
const api = useApi()
const token = jwt.sign(
{
claimingCustomerId: "test_customer",
orders: [orderId],
},
"test"
)
const authResponse = await api.post("/store/auth", {
email: "john@deere.com",
password: "test",
})
const [authCookie] = authResponse.headers["set-cookie"][0].split(";")
const authHeader = {
headers: {
Cookie: authCookie,
},
}
const ordersRes1 = await api.get(`/store/customers/me/orders`, authHeader)
expect(ordersRes1.data.orders.length).toEqual(0)
const response = await api.post("/store/orders/customer/confirm", {
token,
})
expect(response.status).toBe(200)
const ordersRes2 = await api.get(`/store/customers/me/orders`, authHeader)
expect(ordersRes2.data.orders.length).toEqual(1)
})
})
describe("POST /store/customers", () => {
beforeEach(async () => {
const manager = dbConnection.manager
@@ -1,9 +1,9 @@
import { Customer } from "@medusajs/medusa"
import faker from "faker"
import { Customer } from "@medusajs/medusa"
import { Connection } from "typeorm"
import {
CustomerGroupFactoryData,
simpleCustomerGroupFactory
simpleCustomerGroupFactory,
} from "./simple-customer-group-factory"
export type CustomerFactoryData = {
@@ -28,7 +28,7 @@ export const simpleCustomerFactory = async (
const customerId = data.id || `simple-customer-${Math.random() * 1000}`
const c = manager.create(Customer, {
id: customerId,
email: data.email,
email: data.email ?? faker.internet.email(),
password_hash:
data.password_hash ??
"c2NyeXB0AAEAAAABAAAAAVMdaddoGjwU1TafDLLlBKnOTQga7P2dbrfgf3fB+rCD/cJOMuGzAvRdKutbYkVpuJWTU39P7OpuWNkUVoEETOVLMJafbI8qs8Qx/7jMQXkN", // password matching "test"
@@ -27,6 +27,10 @@ import {
SalesChannelFactoryData,
simpleSalesChannelFactory,
} from "./simple-sales-channel-factory"
import {
CustomerFactoryData,
simpleCustomerFactory,
} from "./simple-customer-factory"
export type OrderFactoryData = {
id?: string
@@ -34,6 +38,7 @@ export type OrderFactoryData = {
fulfillment_status?: FulfillmentStatus
region?: RegionFactoryData | string
email?: string | null
customer?: CustomerFactoryData | null
currency_code?: string
tax_rate?: number | null
line_items?: LineItemFactoryData[]
@@ -70,11 +75,10 @@ export const simpleOrderFactory = async (
}
const address = await simpleAddressFactory(connection, data.shipping_address)
const customerToSave = manager.create(Customer, {
email:
typeof data.email !== "undefined" ? data.email : faker.internet.email(),
const customer = await simpleCustomerFactory(connection, {
...data.customer,
email: data.email ?? undefined,
})
const customer = await manager.save(customerToSave)
let discounts = []
if (typeof data.discounts !== "undefined") {
+34 -1
View File
@@ -1,4 +1,9 @@
import { StoreGetOrdersParams, StoreOrdersRes } from "@medusajs/medusa"
import {
StoreGetOrdersParams,
StoreOrdersRes,
StorePostCustomersCustomerAcceptClaimReq,
StorePostCustomersCustomerOrderClaimReq,
} from "@medusajs/medusa"
import qs from "qs"
import { ResponsePromise } from "../typings"
import BaseResource from "./base"
@@ -49,6 +54,34 @@ class OrdersResource extends BaseResource {
return this.client.request("GET", path, payload, {}, customHeaders)
}
/**
* @description Request access to a list of orders
* @param {string[]} payload display ids of orders to request
* @param customHeaders
* @return {ResponsePromise}
*/
requestCustomerOrders(
payload: StorePostCustomersCustomerOrderClaimReq,
customHeaders: Record<string, any> = {}
): ResponsePromise {
const path = `/store/orders/batch/customer/token`
return this.client.request("POST", path, payload, {}, customHeaders)
}
/**
* @description Grant access to a list of orders
* @param {string} payload signed token to grant access
* @param customHeaders
* @return {ResponsePromise}
*/
confirmRequest(
payload: StorePostCustomersCustomerAcceptClaimReq,
customHeaders: Record<string, any> = {}
): ResponsePromise {
const path = `/store/orders/customer/confirm`
return this.client.request("POST", path, payload, {}, customHeaders)
}
}
export default OrdersResource
@@ -121,6 +121,14 @@ export const storeHandlers = [
)
}),
rest.post("/store/orders/customer/confirm", (req, res, ctx) => {
return res(ctx.status(200))
}),
rest.post("/store/orders/batch/customer/token", (req, res, ctx) => {
return res(ctx.status(200))
}),
rest.get("/store/return-reasons/", (req, res, ctx) => {
return res(
ctx.status(200),
@@ -1 +1,2 @@
export * from "./queries"
export * from "./mutations"
@@ -0,0 +1,44 @@
import { useMutation, UseMutationOptions, useQueryClient } from "react-query"
import { Response } from "@medusajs/medusa-js"
import {
StorePostCustomersCustomerAcceptClaimReq,
StorePostCustomersCustomerOrderClaimReq,
} from "@medusajs/medusa"
import { buildOptions } from "../../utils/buildOptions"
import { useMedusa } from "../../../contexts"
import { orderKeys } from "."
export const useRequestOrderAccess = (
options?: UseMutationOptions<
Response<{}>,
Error,
StorePostCustomersCustomerOrderClaimReq
>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
(payload: StorePostCustomersCustomerOrderClaimReq) =>
client.orders.requestCustomerOrders(payload),
buildOptions(queryClient, [orderKeys.all], options)
)
}
export const useGrantOrderAccess = (
options?: UseMutationOptions<
Response<{}>,
Error,
StorePostCustomersCustomerAcceptClaimReq
>
) => {
const { client } = useMedusa()
const queryClient = useQueryClient()
return useMutation(
(payload: StorePostCustomersCustomerAcceptClaimReq) =>
client.orders.confirmRequest(payload),
buildOptions(queryClient, [orderKeys.all], options)
)
}
@@ -0,0 +1,31 @@
import { useRequestOrderAccess, useGrantOrderAccess } from "../../../../src/"
import { renderHook } from "@testing-library/react-hooks"
import { createWrapper } from "../../../utils"
describe("useGrantOrderAccess hook", () => {
test("Grant access to token", async () => {
const { result, waitFor } = renderHook(() => useGrantOrderAccess(), {
wrapper: createWrapper(),
})
result.current.mutate({ token: "store_order_edit" })
await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200)
})
})
describe("useRequestOrderAccess hook", () => {
test("Requests access to ids", async () => {
const { result, waitFor } = renderHook(() => useRequestOrderAccess(), {
wrapper: createWrapper(),
})
result.current.mutate({ order_ids: [""] })
await waitFor(() => result.current.isSuccess)
expect(result.current.data.response.status).toEqual(200)
})
})
@@ -54,8 +54,8 @@ export default async (req, res) => {
try {
const customerService: CustomerService =
req.scope.resolve("customerService")
const customer = await customerService.retrieveByEmail(email, {
select: ["has_account"],
const customer = await customerService.retrieveRegisteredByEmail(email, {
select: ["id", "has_account"],
})
res.status(200).json({ exists: customer.has_account })
} catch (err) {
@@ -137,6 +137,10 @@ export default async (req, res) => {
const cartService: CartService = req.scope.resolve("cartService")
const manager: EntityManager = req.scope.resolve("manager")
if (req.user?.customer_id) {
validated.customer_id = req.user.customer_id
}
await manager.transaction(async (transactionManager) => {
await cartService.withTransaction(transactionManager).update(id, validated)
@@ -8,7 +8,7 @@ describe("POST /store/customers/password-token", () => {
beforeAll(async () => {
subject = await request("POST", `/store/customers/password-token`, {
payload: {
email: "lebron@james.com",
email: "lebron@james1.com",
},
})
})
@@ -18,10 +18,12 @@ describe("POST /store/customers/password-token", () => {
})
it("calls CustomerService retrieve", () => {
expect(CustomerServiceMock.retrieveByEmail).toHaveBeenCalledTimes(1)
expect(CustomerServiceMock.retrieveByEmail).toHaveBeenCalledWith(
"lebron@james.com"
)
expect(
CustomerServiceMock.retrieveRegisteredByEmail
).toHaveBeenCalledTimes(1)
expect(
CustomerServiceMock.retrieveRegisteredByEmail
).toHaveBeenCalledWith("lebron@james1.com")
})
it("calls CustomerService retrieve", () => {
@@ -33,7 +35,7 @@ describe("POST /store/customers/password-token", () => {
).toHaveBeenCalledWith(IdMap.getId("lebron"))
})
it("returns customer decorated", () => {
it("returns success", () => {
expect(subject.status).toEqual(204)
})
})
@@ -9,7 +9,7 @@ describe("POST /store/customers/password-reset", () => {
beforeAll(async () => {
subject = await request("POST", `/store/customers/password-reset`, {
payload: {
email: "lebron@james.com",
email: "lebron@james1.com",
token: jwt.sign({ customer_id: IdMap.getId("lebron") }, "1234"),
password: "TheGame",
},
@@ -47,7 +47,7 @@ describe("POST /store/customers/password-reset", () => {
beforeAll(async () => {
subject = await request("POST", `/store/customers/password-reset`, {
payload: {
email: "lebron@james.com",
email: "lebron@james1.com",
token: jwt.sign({ customer_id: IdMap.getId("not-lebron") }, "1234"),
password: "TheGame",
},
@@ -120,6 +120,11 @@ export default async (req, res) => {
}
)
customer = await customerService.retrieve(customer.id, {
relations: defaultStoreCustomersRelations,
select: defaultStoreCustomersFields,
})
// Add JWT to cookie
const {
projectConfig: { jwt_secret },
@@ -128,19 +133,16 @@ export default async (req, res) => {
expiresIn: "30d",
})
customer = await customerService.retrieve(customer.id, {
relations: defaultStoreCustomersRelations,
select: defaultStoreCustomersFields,
})
res.status(200).json({ customer })
}
export class StorePostCustomersReq {
@IsString()
@IsOptional()
first_name: string
@IsString()
@IsOptional()
last_name: string
@IsEmail()
@@ -1,7 +1,10 @@
import { Router } from "express"
import { Customer, Order } from "../../../.."
import { Customer, Order, StorePostCustomersReq } from "../../../.."
import { PaginatedResponse } from "../../../../types/common"
import middlewares, { transformQuery } from "../../../middlewares"
import middlewares, {
transformBody,
transformQuery,
} from "../../../middlewares"
import {
defaultStoreOrdersFields,
defaultStoreOrdersRelations,
@@ -62,16 +62,18 @@ import { EntityManager } from "typeorm"
* $ref: "#/components/responses/500_error"
*/
export default async (req, res) => {
const validated = await validator(
const validated = (await validator(
StorePostCustomersCustomerPasswordTokenReq,
req.body
)
)) as StorePostCustomersCustomerPasswordTokenReq
const customerService: CustomerService = req.scope.resolve(
"customerService"
) as CustomerService
const customer = await customerService.retrieveByEmail(validated.email)
const customer = await customerService.retrieveRegisteredByEmail(
validated.email
)
// Will generate a token and send it to the customer via an email provider
const manager: EntityManager = req.scope.resolve("manager")
@@ -81,15 +81,18 @@ import { EntityManager } from "typeorm"
* $ref: "#/components/responses/500_error"
*/
export default async (req, res) => {
const validated = await validator(
const validated = (await validator(
StorePostCustomersResetPasswordReq,
req.body
)
)) as StorePostCustomersResetPasswordReq
const customerService: CustomerService = req.scope.resolve("customerService")
let customer = await customerService.retrieveByEmail(validated.email, {
select: ["id", "password_hash"],
})
let customer = await customerService.retrieveRegisteredByEmail(
validated.email,
{
select: ["id", "password_hash"],
}
)
const decodedToken = jwt.verify(
validated.token,
@@ -0,0 +1,117 @@
import { IsJWT, IsNotEmpty } from "class-validator"
import { EntityManager } from "typeorm"
import {
CustomerService,
OrderService,
TokenService,
} from "../../../../services"
/**
* @oas [post] /orders/customer/confirm
* operationId: "PostOrdersCustomerOrderClaimsCustomerOrderClaimAccept"
* summary: "Verify a claim to orders"
* description: "Verifies the claim order token provided to the customer upon request of order ownership"
* requestBody:
* content:
* application/json:
* schema:
* required:
* - token
* properties:
* token:
* description: "The invite token provided by the admin."
* type: string
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.orders.confirmRequest(
* token,
* )
* .then(() => {
* // successful
* })
* .catch(() => {
* // an error occurred
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request POST 'https://medusa-url.com/store/orders/customer/confirm' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "token": "{token}",
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Invite
* responses:
* 200:
* description: OK
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req, res) => {
const { token } = req.validatedBody
const orderSerivce: OrderService = req.scope.resolve("orderService")
const customerService: CustomerService = req.scope.resolve("customerService")
const tokenService: TokenService = req.scope.resolve(
TokenService.RESOLUTION_KEY
)
const orderService: OrderService = req.scope.resolve("orderService")
const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (transactionManager) => {
const { claimingCustomerId, orders: orderIds } = tokenService.verifyToken(
token,
{
maxAge: "15m",
}
) as {
claimingCustomerId: string
orders: string[]
}
const customer = await customerService
.withTransaction(transactionManager)
.retrieve(claimingCustomerId)
const orders = await orderService.list({ id: orderIds })
await Promise.all(
orders.map(async (order) => {
await orderSerivce
.withTransaction(transactionManager)
.update(order.id, {
customer_id: claimingCustomerId,
email: customer.email,
})
})
)
})
res.sendStatus(200)
}
export class StorePostCustomersCustomerAcceptClaimReq {
@IsNotEmpty()
@IsJWT()
token: string
}
@@ -1,7 +1,10 @@
import { Router } from "express"
import "reflect-metadata"
import { Order } from "../../../.."
import middlewares from "../../../middlewares"
import middlewares, { transformBody } from "../../../middlewares"
import requireCustomerAuthentication from "../../../middlewares/require-customer-authentication"
import { StorePostCustomersCustomerOrderClaimReq } from "./request-order"
import { StorePostCustomersCustomerAcceptClaimReq } from "./confirm-order-request"
const route = Router()
@@ -26,6 +29,19 @@ export default (app) => {
middlewares.wrap(require("./get-order-by-cart").default)
)
route.post(
"/customer/confirm",
transformBody(StorePostCustomersCustomerAcceptClaimReq),
middlewares.wrap(require("./confirm-order-request").default)
)
route.post(
"/batch/customer/token",
requireCustomerAuthentication(),
transformBody(StorePostCustomersCustomerOrderClaimReq),
middlewares.wrap(require("./request-order").default)
)
return app
}
@@ -111,3 +127,5 @@ export type StoreOrdersRes = {
}
export * from "./lookup-order"
export * from "./confirm-order-request"
export * from "./request-order"
@@ -0,0 +1,132 @@
import { IsNotEmpty, IsString } from "class-validator"
import { MedusaError } from "medusa-core-utils"
import {
CustomerService,
EventBusService,
OrderService,
} from "../../../../services"
import TokenService from "../../../../services/token"
import { TokenEvents } from "../../../../types/token"
/**
* @oas [post] /orders/batch/customer/token
* operationId: "PostOrdersCustomerOrderClaim"
* summary: "Claim orders for signed in account"
* description: "Sends an email to emails registered to orders provided with link to transfer order ownership"
* requestBody:
* content:
* application/json:
* schema:
* required:
* - order_ids
* properties:
* order_ids:
* description: "The ids of the orders to claim"
* type: array
* items:
* type: string
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.orders.claimOrders({
* display_ids,
* })
* .then(() => {
* // successful
* })
* .catch(() => {
* // an error occurred
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request POST 'https://medusa-url.com/store/batch/customer/token' \
* --header 'Content-Type: application/json' \
* --data-raw '{
* "display_ids": ["id"],
* }'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - Invite
* responses:
* 200:
* description: OK
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req, res) => {
const { order_ids } = req.validatedBody
const eventBusService: EventBusService = req.scope.resolve("eventBusService")
const orderService: OrderService = req.scope.resolve("orderService")
const customerService: CustomerService = req.scope.resolve("customerService")
const tokenService: TokenService = req.scope.resolve(
TokenService.RESOLUTION_KEY
)
const customerId: string = req.user?.customer_id
const customer = await customerService.retrieve(customerId)
if (!customer.has_account) {
throw new MedusaError(
MedusaError.Types.UNAUTHORIZED,
"Customer does not have an account"
)
}
const orders = await orderService.list(
{ id: order_ids },
{ select: ["id", "email"] }
)
const emailOrderMapping: { [email: string]: string[] } = orders.reduce(
(acc, order) => {
acc[order.email] = [...(acc[order.email] || []), order.id]
return acc
},
{}
)
await Promise.all(
Object.entries(emailOrderMapping).map(async ([email, order_ids]) => {
const token = tokenService.signToken(
{
claimingCustomerId: customerId,
orders: order_ids,
},
{ expiresIn: "15m" }
)
await eventBusService.emit(TokenEvents.ORDER_UPDATE_TOKEN_CREATED, {
old_email: email,
new_customer_id: customer.id,
orders: order_ids,
token,
})
})
)
res.sendStatus(200)
}
export class StorePostCustomersCustomerOrderClaimReq {
@IsNotEmpty({ each: true })
@IsString({ each: true })
order_ids: string[]
}
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from "typeorm"
export class updateCustomerEmailConstraint_1669032280562
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "public"."IDX_fdb2f3ad8115da4c7718109a6e"`
)
await queryRunner.query(
`ALTER TABLE "customer" ADD CONSTRAINT "UQ_unique_email_for_guests_and_customer_accounts" UNIQUE ("email", "has_account")`
)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "customer" DROP CONSTRAINT "UQ_unique_email_for_guests_and_customer_accounts"`
)
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_fdb2f3ad8115da4c7718109a6e" ON "customer" ("email") `
)
}
}
+3 -1
View File
@@ -8,6 +8,7 @@ import {
ManyToMany,
OneToMany,
OneToOne,
Unique,
} from "typeorm"
import { Address } from "./address"
@@ -18,8 +19,9 @@ import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity"
import { generateEntityId } from "../utils/generate-entity-id"
@Entity()
@Unique(["email", "has_account"])
export class Customer extends SoftDeletableEntity {
@Index({ unique: true })
@Index()
@Column()
email: string
@@ -46,12 +46,23 @@ export const CustomerServiceMock = {
password_hash: "1234",
})
}
return Promise.resolve(undefined)
}),
retrieveRegisteredByEmail: jest.fn().mockImplementation((email) => {
if (email === "oliver@test.dk") {
return Scrypt.kdf("123456789", { logN: 1, r: 1, p: 1 }).then((hash) => ({
email,
password_hash: hash.toString("base64"),
has_account: true,
}))
}
if (email === "lebron@james1.com") {
return Promise.resolve({
id: IdMap.getId("lebron"),
email,
password_hash: "1234",
})
}
return Promise.resolve(undefined)
}),
}
@@ -9,7 +9,7 @@ describe("AuthService", () => {
const authService = new AuthService({
manager: managerMock,
userService: UserServiceMock,
customerService: CustomerServiceMock
customerService: CustomerServiceMock,
})
describe("authenticate", () => {
+12 -2
View File
@@ -167,10 +167,18 @@ describe("CartService", () => {
})
const cartRepository = MockRepository()
const customerService = {
retrieveByEmail: jest.fn().mockReturnValue(
retrieveUnregisteredByEmail: jest.fn().mockReturnValue(
Promise.resolve({
id: IdMap.getId("customer"),
email: "email@test.com",
has_account: false,
})
),
retrieveRegisteredByEmail: jest.fn().mockReturnValue(
Promise.resolve({
id: IdMap.getId("customer"),
email: "email@test.com",
has_account: true,
})
),
withTransaction: function () {
@@ -970,12 +978,13 @@ describe("CartService", () => {
describe("updateEmail", () => {
const customerService = {
retrieveByEmail: jest.fn().mockImplementation((email) => {
retrieveUnregisteredByEmail: jest.fn().mockImplementation((email) => {
if (email === "no@mail.com") {
return Promise.reject()
}
return Promise.resolve({
id: IdMap.getId("existing"),
has_account: false,
email,
})
}),
@@ -1025,6 +1034,7 @@ describe("CartService", () => {
customer: {
id: IdMap.getId("existing"),
email: "test@testdom.com",
has_account: false,
},
email: "test@testdom.com",
})
@@ -64,6 +64,32 @@ describe("CustomerService", () => {
expect(result.id).toEqual(IdMap.getId("ironman"))
})
it("successfully retrieves a guest customer by email", async () => {
const result = await customerService.retrieveUnregisteredByEmail(
"tony@stark.com"
)
expect(customerRepository.findOne).toHaveBeenCalledTimes(1)
expect(customerRepository.findOne).toHaveBeenCalledWith({
where: { email: "tony@stark.com", has_account: false },
})
expect(result.id).toEqual(IdMap.getId("ironman"))
})
it("successfully retrieves a registered customer by email", async () => {
const result = await customerService.retrieveRegisteredByEmail(
"tony@stark.com"
)
expect(customerRepository.findOne).toHaveBeenCalledTimes(1)
expect(customerRepository.findOne).toHaveBeenCalledWith({
where: { email: "tony@stark.com", has_account: true },
})
expect(result.id).toEqual(IdMap.getId("ironman"))
})
})
describe("retrieveByPhone", () => {
@@ -92,57 +118,118 @@ describe("CustomerService", () => {
})
describe("create", () => {
const customerRepository = MockRepository({
findOne: (query) => {
const customerRepository = {
...MockRepository({
findOne: (query) => {
if (query.where.email === "tony@stark.com") {
return Promise.resolve({
id: IdMap.getId("exists"),
has_account: true,
password_hash: "test",
})
}
return undefined
},
}),
listAndCount: jest.fn().mockImplementation((query, q) => {
if (query.where.email === "tony@stark.com") {
return Promise.resolve({
id: IdMap.getId("exists"),
password_hash: "test",
})
return Promise.resolve([
[
{
id: IdMap.getId("exists"),
has_account: true,
password_hash: "test",
},
],
0,
])
}
return Promise.resolve({ id: IdMap.getId("ironman") })
},
})
return Promise.resolve([[], 0])
}),
}
const configModule = {
projectConfig: {
jwt_secret: "test",
},
}
const customerService = new CustomerService({
manager: MockManager,
customerRepository,
eventBusService,
configModule,
})
beforeEach(async () => {
jest.clearAllMocks()
})
it("successfully create a customer", async () => {
it("successfully creates a customer with password", async () => {
await customerService.create({
email: "oliver@medusa.com",
first_name: "Oliver",
last_name: "Juhl",
email: "john@doe.com",
first_name: "John",
last_name: "Doe",
password: "test",
})
expect(customerRepository.create).toBeCalledTimes(1)
expect(customerRepository.create).toBeCalledWith({
email: "oliver@medusa.com",
first_name: "Oliver",
last_name: "Juhl",
email: "john@doe.com",
first_name: "John",
last_name: "Doe",
has_account: true,
password_hash: expect.anything(),
})
})
it("successfully updates an existing customer on create", async () => {
it("calls listAndCount with email", async () => {
await customerService.create({
email: "tony@stark.com",
password: "stark123",
has_account: false,
email: "john@doe.com",
first_name: "John",
last_name: "Doe",
password: "test",
})
expect(customerRepository.save).toBeCalledTimes(1)
expect(customerRepository.save).toBeCalledWith({
id: IdMap.getId("exists"),
email: "tony@stark.com",
password_hash: expect.anything(),
has_account: true,
expect(customerRepository.listAndCount).toHaveBeenCalledWith(
{
relations: [],
skip: 0,
take: 2,
where: {
email: "john@doe.com",
},
},
undefined
)
})
it("successfully creates a one time customer", async () => {
await customerService.create({
email: "john@doe.com",
first_name: "John",
last_name: "Doe",
})
expect(customerRepository.create).toBeCalledTimes(1)
expect(customerRepository.create).toBeCalledWith({
email: "john@doe.com",
first_name: "John",
last_name: "Doe",
})
})
it("Fails to create a customer with an existing account", async () => {
expect.assertions(1)
await customerService
.create({
email: "tony@stark.com",
password: "stark123",
})
.catch((err) => {
expect(err.message).toEqual(
"A customer with the given email already has an account. Log in instead"
)
})
})
})
+6 -6
View File
@@ -149,21 +149,21 @@ class AuthService extends TransactionBaseService {
): Promise<AuthenticateResult> {
return await this.atomicPhase_(async (transactionManager) => {
try {
const customerPasswordHash: Customer = await this.customerService_
const customer: Customer = await this.customerService_
.withTransaction(transactionManager)
.retrieveByEmail(email, {
select: ["password_hash"],
.retrieveRegisteredByEmail(email, {
select: ["id", "password_hash"],
})
if (customerPasswordHash.password_hash) {
if (customer.password_hash) {
const passwordsMatch = await this.comparePassword_(
password,
customerPasswordHash.password_hash
customer.password_hash
)
if (passwordsMatch) {
const customer = await this.customerService_
.withTransaction(transactionManager)
.retrieveByEmail(email)
.retrieveRegisteredByEmail(email)
return {
success: true,
+19 -5
View File
@@ -325,8 +325,20 @@ class CartService extends TransactionBaseService {
).id
}
if (data.email) {
const customer = await this.createOrFetchUserFromEmail_(data.email)
if (data.customer_id) {
const customer = await this.customerService_
.withTransaction(transactionManager)
.retrieve(data.customer_id)
.catch(() => undefined)
rawCart.customer = customer
rawCart.customer_id = customer?.id
rawCart.email = customer?.email
}
if (!rawCart.email && data.email) {
const customer = await this.createOrFetchGuestCustomerFromEmail_(
data.email
)
rawCart.customer = customer
rawCart.customer_id = customer.id
rawCart.email = customer.email
@@ -992,7 +1004,9 @@ class CartService extends TransactionBaseService {
if (data.customer_id) {
await this.updateCustomerId_(cart, data.customer_id)
} else if (isDefined(data.email)) {
const customer = await this.createOrFetchUserFromEmail_(data.email)
const customer = await this.createOrFetchGuestCustomerFromEmail_(
data.email
)
cart.customer = customer
cart.customer_id = customer.id
cart.email = customer.email
@@ -1181,14 +1195,14 @@ class CartService extends TransactionBaseService {
* @param email - the email to use
* @return the resultign customer object
*/
protected async createOrFetchUserFromEmail_(
protected async createOrFetchGuestCustomerFromEmail_(
email: string
): Promise<Customer> {
const validatedEmail = validateEmail(email)
let customer = await this.customerService_
.withTransaction(this.transactionManager_)
.retrieveByEmail(validatedEmail)
.retrieveUnregisteredByEmail(validatedEmail)
.catch(() => undefined)
if (!customer) {
+57 -30
View File
@@ -2,6 +2,7 @@ import jwt from "jsonwebtoken"
import { MedusaError } from "medusa-core-utils"
import Scrypt from "scrypt-kdf"
import { DeepPartial, EntityManager } from "typeorm"
import { EventBusService } from "."
import { StorePostCustomersCustomerAddressesAddressReq } from "../api"
import { TransactionBaseService } from "../interfaces"
import { Address, Customer, CustomerGroup } from "../models"
@@ -10,7 +11,6 @@ import { CustomerRepository } from "../repositories/customer"
import { AddressCreatePayload, FindConfig, Selector } from "../types/common"
import { CreateCustomerInput, UpdateCustomerInput } from "../types/customers"
import { buildQuery, isDefined, setMetadata } from "../utils"
import EventBusService from "./event-bus"
type InjectedDependencies = {
manager: EntityManager
@@ -186,10 +186,11 @@ class CustomerService extends TransactionBaseService {
}
/**
* Gets a customer by email.
* Gets a registered customer by email.
* @param {string} email - the email of the customer to get.
* @param {Object} config - the config object containing query settings
* @return {Promise<Customer>} the customer document.
* @deprecated
*/
async retrieveByEmail(
email: string,
@@ -198,6 +199,31 @@ class CustomerService extends TransactionBaseService {
return await this.retrieve_({ email: email.toLowerCase() }, config)
}
async retrieveUnregisteredByEmail(
email: string,
config: FindConfig<Customer> = {}
): Promise<Customer | never> {
return await this.retrieve_(
{ email: email.toLowerCase(), has_account: false },
config
)
}
async retrieveRegisteredByEmail(
email: string,
config: FindConfig<Customer> = {}
): Promise<Customer | never> {
return await this.retrieve_(
{ email: email.toLowerCase(), has_account: true },
config
)
}
async listByEmail(
email: string,
config: FindConfig<Customer> = { relations: [], skip: 0, take: 2 }
): Promise<Customer[]> {
return await this.list({ email: email.toLowerCase() }, config)
}
/**
* Gets a customer by phone.
* @param {string} phone - the phone of the customer to get.
@@ -249,44 +275,45 @@ class CustomerService extends TransactionBaseService {
)
customer.email = customer.email.toLowerCase()
const { email, password } = customer
const existing = await this.retrieveByEmail(email).catch(() => undefined)
// should be a list of customers at this point
const existing = await this.listByEmail(email).catch(() => undefined)
if (existing && existing.has_account) {
throw new MedusaError(
MedusaError.Types.DUPLICATE_ERROR,
"A customer with the given email already has an account. Log in instead"
)
// should validate that "existing.some(acc => acc.has_account) && password"
if (existing) {
if (existing.some((customer) => customer.has_account) && password) {
throw new MedusaError(
MedusaError.Types.DUPLICATE_ERROR,
"A customer with the given email already has an account. Log in instead"
)
} else if (
existing?.some((customer) => !customer.has_account) &&
!password
) {
throw new MedusaError(
MedusaError.Types.DUPLICATE_ERROR,
"Guest customer with email already exists"
)
}
}
if (existing && password && !existing.has_account) {
if (password) {
const hashedPassword = await this.hashPassword_(password)
customer.password_hash = hashedPassword
customer.has_account = true
delete customer.password
const toUpdate = { ...existing, ...customer }
const updated = await customerRepository.save(toUpdate)
await this.eventBusService_
.withTransaction(manager)
.emit(CustomerService.Events.UPDATED, updated)
return updated
} else {
if (password) {
const hashedPassword = await this.hashPassword_(password)
customer.password_hash = hashedPassword
customer.has_account = true
delete customer.password
}
const created = customerRepository.create(customer)
const result = await customerRepository.save(created)
await this.eventBusService_
.withTransaction(manager)
.emit(CustomerService.Events.CREATED, result)
return result
}
const created = customerRepository.create(customer)
const result = await customerRepository.save(created)
await this.eventBusService_
.withTransaction(manager)
.emit(CustomerService.Events.CREATED, result)
return result
})
}
+1
View File
@@ -50,6 +50,7 @@ export { default as SwapService } from "./swap"
export { default as SystemPaymentProviderService } from "./system-payment-provider"
export { default as TaxProviderService } from "./tax-provider"
export { default as TaxRateService } from "./tax-rate"
export { default as TokenService } from "./token"
export { default as TotalsService } from "./totals"
export { default as NewTotalsService } from "./new-totals"
export { default as UserService } from "./user"
+4
View File
@@ -1,3 +1,4 @@
import jwt, { JwtPayload } from "jsonwebtoken"
import { MedusaError } from "medusa-core-utils"
import { Brackets, EntityManager } from "typeorm"
import { TransactionBaseService } from "../interfaces"
@@ -45,6 +46,8 @@ import ShippingOptionService from "./shipping-option"
import ShippingProfileService from "./shipping-profile"
import TotalsService from "./totals"
import { NewTotalsService, TaxProviderService } from "./index"
import { ConfigModule } from "../types/global"
import logger from "../loaders/logger"
export const ORDER_CART_ALREADY_EXISTS_ERROR = "Order from cart already exists"
@@ -94,6 +97,7 @@ class OrderService extends TransactionBaseService {
UPDATED: "order.updated",
CANCELED: "order.canceled",
COMPLETED: "order.completed",
ORDERS_CLAIMED: "order.orders_claimed",
}
protected manager_: EntityManager
+47
View File
@@ -0,0 +1,47 @@
import jwt, { Jwt, JwtPayload, SignOptions, VerifyOptions } from "jsonwebtoken"
import { ConfigModule } from "../types/global"
import formatRegistrationName from "../utils/format-registration-name"
import { resolve } from "path"
import { MedusaError } from "medusa-core-utils"
type InjectedDependencies = {
configModule: ConfigModule
}
class TokenService {
static RESOLUTION_KEY = formatRegistrationName(resolve(__dirname, __filename))
protected readonly configModule_: ConfigModule
constructor({ configModule }: InjectedDependencies) {
this.configModule_ = configModule
}
verifyToken(
token: string,
options?: VerifyOptions
): Jwt | JwtPayload | string {
const { jwt_secret } = this.configModule_.projectConfig
if (jwt_secret) {
return jwt.verify(token, jwt_secret, options)
}
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Please configure jwt_secret"
)
}
signToken(data: string | Buffer | object, options?: SignOptions): string {
const { jwt_secret } = this.configModule_.projectConfig
if (jwt_secret) {
return jwt.sign(data, jwt_secret, options)
} else {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
"Please configure a jwt token"
)
}
}
}
export default TokenService
+3
View File
@@ -0,0 +1,3 @@
export enum TokenEvents {
ORDER_UPDATE_TOKEN_CREATED = "order-update-token.created",
}