From a6243618fef5f9dd51ccba9e07e7849dd177202c Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Thu, 8 Dec 2022 17:48:49 +0100 Subject: [PATCH] feat(medusa): Claim customer orders (#2710) --- .changeset/tall-beds-enjoy.md | 7 + .../api/__tests__/admin/invite.js | 1 - .../api/__tests__/admin/payment-collection.js | 2 +- .../api/__tests__/store/cart/cart.js | 50 +++++++ .../api/__tests__/store/customer.js | 74 ++++++++++ .../api/factories/simple-customer-factory.ts | 6 +- .../api/factories/simple-order-factory.ts | 12 +- packages/medusa-js/src/resources/orders.ts | 35 ++++- packages/medusa-react/mocks/handlers/store.ts | 8 + .../src/hooks/store/orders/index.ts | 1 + .../src/hooks/store/orders/mutations.ts | 44 ++++++ .../test/hooks/store/orders/mutations.test.ts | 31 ++++ .../src/api/routes/store/auth/exists.ts | 4 +- .../src/api/routes/store/carts/update-cart.ts | 4 + .../__tests__/reset-password-token.js | 14 +- .../customers/__tests__/reset-password.js | 4 +- .../routes/store/customers/create-customer.ts | 12 +- .../src/api/routes/store/customers/index.ts | 7 +- .../store/customers/reset-password-token.ts | 8 +- .../routes/store/customers/reset-password.ts | 13 +- .../store/orders/confirm-order-request.ts | 117 +++++++++++++++ .../src/api/routes/store/orders/index.ts | 20 ++- .../api/routes/store/orders/request-order.ts | 132 +++++++++++++++++ ...280562-update_customer_email_constraint.ts | 25 ++++ packages/medusa/src/models/customer.ts | 4 +- .../medusa/src/services/__mocks__/customer.js | 11 ++ .../medusa/src/services/__tests__/auth.js | 2 +- .../medusa/src/services/__tests__/cart.js | 14 +- .../medusa/src/services/__tests__/customer.js | 139 ++++++++++++++---- packages/medusa/src/services/auth.ts | 12 +- packages/medusa/src/services/cart.ts | 24 ++- packages/medusa/src/services/customer.ts | 87 +++++++---- packages/medusa/src/services/index.ts | 1 + packages/medusa/src/services/order.ts | 4 + packages/medusa/src/services/token.ts | 47 ++++++ packages/medusa/src/types/token.ts | 3 + 36 files changed, 872 insertions(+), 107 deletions(-) create mode 100644 .changeset/tall-beds-enjoy.md create mode 100644 packages/medusa-react/src/hooks/store/orders/mutations.ts create mode 100644 packages/medusa-react/test/hooks/store/orders/mutations.test.ts create mode 100644 packages/medusa/src/api/routes/store/orders/confirm-order-request.ts create mode 100644 packages/medusa/src/api/routes/store/orders/request-order.ts create mode 100644 packages/medusa/src/migrations/1669032280562-update_customer_email_constraint.ts create mode 100644 packages/medusa/src/services/token.ts create mode 100644 packages/medusa/src/types/token.ts diff --git a/.changeset/tall-beds-enjoy.md b/.changeset/tall-beds-enjoy.md new file mode 100644 index 0000000000..468398468c --- /dev/null +++ b/.changeset/tall-beds-enjoy.md @@ -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: })` in the future diff --git a/integration-tests/api/__tests__/admin/invite.js b/integration-tests/api/__tests__/admin/invite.js index d4f0189fdf..c9b1b33343 100644 --- a/integration-tests/api/__tests__/admin/invite.js +++ b/integration-tests/api/__tests__/admin/invite.js @@ -1,4 +1,3 @@ -const jwt = require("jsonwebtoken") const path = require("path") const setupServer = require("../../../helpers/setup-server") diff --git a/integration-tests/api/__tests__/admin/payment-collection.js b/integration-tests/api/__tests__/admin/payment-collection.js index 1ce01e7456..e99a656724 100644 --- a/integration-tests/api/__tests__/admin/payment-collection.js +++ b/integration-tests/api/__tests__/admin/payment-collection.js @@ -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 diff --git a/integration-tests/api/__tests__/store/cart/cart.js b/integration-tests/api/__tests__/store/cart/cart.js index ceca343ab2..8db9528163 100644 --- a/integration-tests/api/__tests__/store/cart/cart.js +++ b/integration-tests/api/__tests__/store/cart/cart.js @@ -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", () => { diff --git a/integration-tests/api/__tests__/store/customer.js b/integration-tests/api/__tests__/store/customer.js index c85bff0855..191359a015 100644 --- a/integration-tests/api/__tests__/store/customer.js +++ b/integration-tests/api/__tests__/store/customer.js @@ -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 diff --git a/integration-tests/api/factories/simple-customer-factory.ts b/integration-tests/api/factories/simple-customer-factory.ts index 1a42f1ac5e..9e01859d70 100644 --- a/integration-tests/api/factories/simple-customer-factory.ts +++ b/integration-tests/api/factories/simple-customer-factory.ts @@ -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" diff --git a/integration-tests/api/factories/simple-order-factory.ts b/integration-tests/api/factories/simple-order-factory.ts index 4b8cc403fb..c7aa1bec60 100644 --- a/integration-tests/api/factories/simple-order-factory.ts +++ b/integration-tests/api/factories/simple-order-factory.ts @@ -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") { diff --git a/packages/medusa-js/src/resources/orders.ts b/packages/medusa-js/src/resources/orders.ts index cf9585a58d..e2b73385e1 100644 --- a/packages/medusa-js/src/resources/orders.ts +++ b/packages/medusa-js/src/resources/orders.ts @@ -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 = {} + ): 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 = {} + ): ResponsePromise { + const path = `/store/orders/customer/confirm` + return this.client.request("POST", path, payload, {}, customHeaders) + } } export default OrdersResource diff --git a/packages/medusa-react/mocks/handlers/store.ts b/packages/medusa-react/mocks/handlers/store.ts index e014975b07..aa8b8cb2bf 100644 --- a/packages/medusa-react/mocks/handlers/store.ts +++ b/packages/medusa-react/mocks/handlers/store.ts @@ -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), diff --git a/packages/medusa-react/src/hooks/store/orders/index.ts b/packages/medusa-react/src/hooks/store/orders/index.ts index f3593df2df..a494946b87 100644 --- a/packages/medusa-react/src/hooks/store/orders/index.ts +++ b/packages/medusa-react/src/hooks/store/orders/index.ts @@ -1 +1,2 @@ export * from "./queries" +export * from "./mutations" diff --git a/packages/medusa-react/src/hooks/store/orders/mutations.ts b/packages/medusa-react/src/hooks/store/orders/mutations.ts new file mode 100644 index 0000000000..db0a7446b5 --- /dev/null +++ b/packages/medusa-react/src/hooks/store/orders/mutations.ts @@ -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) + ) +} diff --git a/packages/medusa-react/test/hooks/store/orders/mutations.test.ts b/packages/medusa-react/test/hooks/store/orders/mutations.test.ts new file mode 100644 index 0000000000..35c7edcacf --- /dev/null +++ b/packages/medusa-react/test/hooks/store/orders/mutations.test.ts @@ -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) + }) +}) diff --git a/packages/medusa/src/api/routes/store/auth/exists.ts b/packages/medusa/src/api/routes/store/auth/exists.ts index cde7d4b4fe..fd9c73acae 100644 --- a/packages/medusa/src/api/routes/store/auth/exists.ts +++ b/packages/medusa/src/api/routes/store/auth/exists.ts @@ -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) { diff --git a/packages/medusa/src/api/routes/store/carts/update-cart.ts b/packages/medusa/src/api/routes/store/carts/update-cart.ts index d09eacc37f..96ba3771a1 100644 --- a/packages/medusa/src/api/routes/store/carts/update-cart.ts +++ b/packages/medusa/src/api/routes/store/carts/update-cart.ts @@ -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) diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/reset-password-token.js b/packages/medusa/src/api/routes/store/customers/__tests__/reset-password-token.js index 36bc037135..07d86d9252 100644 --- a/packages/medusa/src/api/routes/store/customers/__tests__/reset-password-token.js +++ b/packages/medusa/src/api/routes/store/customers/__tests__/reset-password-token.js @@ -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) }) }) diff --git a/packages/medusa/src/api/routes/store/customers/__tests__/reset-password.js b/packages/medusa/src/api/routes/store/customers/__tests__/reset-password.js index 8b8037e6cc..f11231eeef 100644 --- a/packages/medusa/src/api/routes/store/customers/__tests__/reset-password.js +++ b/packages/medusa/src/api/routes/store/customers/__tests__/reset-password.js @@ -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", }, diff --git a/packages/medusa/src/api/routes/store/customers/create-customer.ts b/packages/medusa/src/api/routes/store/customers/create-customer.ts index 73be65a704..b3fb9d2446 100644 --- a/packages/medusa/src/api/routes/store/customers/create-customer.ts +++ b/packages/medusa/src/api/routes/store/customers/create-customer.ts @@ -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() diff --git a/packages/medusa/src/api/routes/store/customers/index.ts b/packages/medusa/src/api/routes/store/customers/index.ts index 7ca03be055..82b7ff9149 100644 --- a/packages/medusa/src/api/routes/store/customers/index.ts +++ b/packages/medusa/src/api/routes/store/customers/index.ts @@ -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, diff --git a/packages/medusa/src/api/routes/store/customers/reset-password-token.ts b/packages/medusa/src/api/routes/store/customers/reset-password-token.ts index f01f99bf63..77777ee30b 100644 --- a/packages/medusa/src/api/routes/store/customers/reset-password-token.ts +++ b/packages/medusa/src/api/routes/store/customers/reset-password-token.ts @@ -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") diff --git a/packages/medusa/src/api/routes/store/customers/reset-password.ts b/packages/medusa/src/api/routes/store/customers/reset-password.ts index 14eda20443..a8b8e5ee15 100644 --- a/packages/medusa/src/api/routes/store/customers/reset-password.ts +++ b/packages/medusa/src/api/routes/store/customers/reset-password.ts @@ -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, diff --git a/packages/medusa/src/api/routes/store/orders/confirm-order-request.ts b/packages/medusa/src/api/routes/store/orders/confirm-order-request.ts new file mode 100644 index 0000000000..1c8e426e45 --- /dev/null +++ b/packages/medusa/src/api/routes/store/orders/confirm-order-request.ts @@ -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 +} diff --git a/packages/medusa/src/api/routes/store/orders/index.ts b/packages/medusa/src/api/routes/store/orders/index.ts index 29f301a45e..e87db3fcc9 100644 --- a/packages/medusa/src/api/routes/store/orders/index.ts +++ b/packages/medusa/src/api/routes/store/orders/index.ts @@ -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" diff --git a/packages/medusa/src/api/routes/store/orders/request-order.ts b/packages/medusa/src/api/routes/store/orders/request-order.ts new file mode 100644 index 0000000000..6ea7b3a007 --- /dev/null +++ b/packages/medusa/src/api/routes/store/orders/request-order.ts @@ -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[] +} diff --git a/packages/medusa/src/migrations/1669032280562-update_customer_email_constraint.ts b/packages/medusa/src/migrations/1669032280562-update_customer_email_constraint.ts new file mode 100644 index 0000000000..2e4a975ca5 --- /dev/null +++ b/packages/medusa/src/migrations/1669032280562-update_customer_email_constraint.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class updateCustomerEmailConstraint_1669032280562 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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") ` + ) + } +} diff --git a/packages/medusa/src/models/customer.ts b/packages/medusa/src/models/customer.ts index 759b1a9baf..016a895134 100644 --- a/packages/medusa/src/models/customer.ts +++ b/packages/medusa/src/models/customer.ts @@ -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 diff --git a/packages/medusa/src/services/__mocks__/customer.js b/packages/medusa/src/services/__mocks__/customer.js index d6d80a8dd7..be43c9f564 100644 --- a/packages/medusa/src/services/__mocks__/customer.js +++ b/packages/medusa/src/services/__mocks__/customer.js @@ -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) }), } diff --git a/packages/medusa/src/services/__tests__/auth.js b/packages/medusa/src/services/__tests__/auth.js index 5b39e33a37..556495900c 100644 --- a/packages/medusa/src/services/__tests__/auth.js +++ b/packages/medusa/src/services/__tests__/auth.js @@ -9,7 +9,7 @@ describe("AuthService", () => { const authService = new AuthService({ manager: managerMock, userService: UserServiceMock, - customerService: CustomerServiceMock + customerService: CustomerServiceMock, }) describe("authenticate", () => { diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index dcead01ab1..4463eefd88 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -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", }) diff --git a/packages/medusa/src/services/__tests__/customer.js b/packages/medusa/src/services/__tests__/customer.js index 71809e6e30..4e01a03f9d 100644 --- a/packages/medusa/src/services/__tests__/customer.js +++ b/packages/medusa/src/services/__tests__/customer.js @@ -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" + ) + }) }) }) diff --git a/packages/medusa/src/services/auth.ts b/packages/medusa/src/services/auth.ts index 3dc12f9e05..f980c7dafb 100644 --- a/packages/medusa/src/services/auth.ts +++ b/packages/medusa/src/services/auth.ts @@ -149,21 +149,21 @@ class AuthService extends TransactionBaseService { ): Promise { 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, diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index ac6de30573..9db799c953 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -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 { const validatedEmail = validateEmail(email) let customer = await this.customerService_ .withTransaction(this.transactionManager_) - .retrieveByEmail(validatedEmail) + .retrieveUnregisteredByEmail(validatedEmail) .catch(() => undefined) if (!customer) { diff --git a/packages/medusa/src/services/customer.ts b/packages/medusa/src/services/customer.ts index 7a8c63ea8d..f3b22c6d4f 100644 --- a/packages/medusa/src/services/customer.ts +++ b/packages/medusa/src/services/customer.ts @@ -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} 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 = {} + ): Promise { + return await this.retrieve_( + { email: email.toLowerCase(), has_account: false }, + config + ) + } + async retrieveRegisteredByEmail( + email: string, + config: FindConfig = {} + ): Promise { + return await this.retrieve_( + { email: email.toLowerCase(), has_account: true }, + config + ) + } + + async listByEmail( + email: string, + config: FindConfig = { relations: [], skip: 0, take: 2 } + ): Promise { + 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 }) } diff --git a/packages/medusa/src/services/index.ts b/packages/medusa/src/services/index.ts index b4aa7673e4..51b97cdc71 100644 --- a/packages/medusa/src/services/index.ts +++ b/packages/medusa/src/services/index.ts @@ -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" diff --git a/packages/medusa/src/services/order.ts b/packages/medusa/src/services/order.ts index f207a6a241..3759aef96c 100644 --- a/packages/medusa/src/services/order.ts +++ b/packages/medusa/src/services/order.ts @@ -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 diff --git a/packages/medusa/src/services/token.ts b/packages/medusa/src/services/token.ts new file mode 100644 index 0000000000..193b8c6e5d --- /dev/null +++ b/packages/medusa/src/services/token.ts @@ -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 diff --git a/packages/medusa/src/types/token.ts b/packages/medusa/src/types/token.ts new file mode 100644 index 0000000000..01c8e3ef00 --- /dev/null +++ b/packages/medusa/src/types/token.ts @@ -0,0 +1,3 @@ +export enum TokenEvents { + ORDER_UPDATE_TOKEN_CREATED = "order-update-token.created", +}