feat(medusa): Claim customer orders (#2710)
This commit is contained in:
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user