feat(medusa): Claim customer orders (#2710)
This commit is contained in:
1 parent
86f9455d00
commit
a6243618fe
36 files changed
+872
-107
No files matched your search
@@ -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") `
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum TokenEvents {
|
||||
ORDER_UPDATE_TOKEN_CREATED = "order-update-token.created",
|
||||
}
|
||||
Reference in new issue
Block a user