From 328eb85a8b24b98bc33230f03ef2e105c4329deb Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Tue, 30 Jan 2024 11:32:16 +0100 Subject: [PATCH 01/11] feat(): added compute actions for buyget promotions (#6255) what: - computes actions for buyget promotion (RESOLVES CORE-1700) --- .../promotion-module/compute-actions.spec.ts | 424 ++++++++++++++++-- .../src/services/promotion-module.ts | 82 ++-- .../src/utils/compute-actions/buy-get.ts | 101 +++++ .../src/utils/compute-actions/index.ts | 1 + 4 files changed, 542 insertions(+), 66 deletions(-) create mode 100644 packages/promotion/src/utils/compute-actions/buy-get.ts diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index 4b97bdd521a1a..09c7cdcaaf68d 100644 --- a/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -38,44 +38,40 @@ describe("Promotion Service: computeActions", () => { }) describe("when code is not present in database", () => { - it("should throw error when code in promotions array does not exist", async () => { - const error = await service - .computeActions(["DOES_NOT_EXIST"], { - customer: { - customer_group: { - id: "VIP", + it("should return empty array when promotion does not exist", async () => { + const response = await service.computeActions(["DOES_NOT_EXIST"], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", }, }, - items: [ - { - id: "item_cotton_tshirt", - quantity: 1, - unit_price: 100, - product_category: { - id: "catg_cotton", - }, - product: { - id: "prod_tshirt", - }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + product_category: { + id: "catg_cotton", }, - { - id: "item_cotton_sweater", - quantity: 5, - unit_price: 150, - product_category: { - id: "catg_cotton", - }, - product: { - id: "prod_sweater", - }, + product: { + id: "prod_sweater", }, - ], - }) - .catch((e) => e) + }, + ], + }) - expect(error.message).toContain( - "Promotion for code (DOES_NOT_EXIST) not found" - ) + expect(response).toEqual([]) }) it("should throw error when code in items adjustment does not exist", async () => { @@ -2315,4 +2311,366 @@ describe("Promotion Service: computeActions", () => { ]) }) }) + + describe("when promotion of type buyget", () => { + it("should compute adjustment when target and buy rules match", async () => { + const context = { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 2, + unit_price: 500, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_1", + }, + }, + { + id: "item_cotton_tshirt2", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_2", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_sweater", + }, + product: { + id: "prod_sweater_1", + }, + }, + ], + } + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.BUYGET, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 1, + apply_to_quantity: 1, + buy_rules_min_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_tshirt"], + }, + ], + buy_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_sweater"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], context) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt2", + amount: 1000, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should return empty array when conditions for minimum qty aren't met", async () => { + const context = { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 2, + unit_price: 500, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_1", + }, + }, + { + id: "item_cotton_tshirt2", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_2", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_sweater", + }, + product: { + id: "prod_sweater_1", + }, + }, + ], + } + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.BUYGET, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 1, + apply_to_quantity: 1, + buy_rules_min_quantity: 4, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_tshirt"], + }, + ], + buy_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_sweater"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], context) + + expect(result).toEqual([]) + }) + + it("should compute actions for multiple items when conditions for target qty exceed one item", async () => { + const context = { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 2, + unit_price: 500, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_1", + }, + }, + { + id: "item_cotton_tshirt2", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_2", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_sweater", + }, + product: { + id: "prod_sweater_1", + }, + }, + ], + } + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.BUYGET, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 1, + apply_to_quantity: 4, + buy_rules_min_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_tshirt"], + }, + ], + buy_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_sweater"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], context) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt2", + amount: 2000, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 1000, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should return empty array when target rules arent met with context", async () => { + const context = { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 2, + unit_price: 500, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_1", + }, + }, + { + id: "item_cotton_tshirt2", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_tshirt", + }, + product: { + id: "prod_tshirt_2", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 1000, + product_category: { + id: "catg_sweater", + }, + product: { + id: "prod_sweater_1", + }, + }, + ], + } + + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.BUYGET, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + max_quantity: 1, + apply_to_quantity: 4, + buy_rules_min_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_not-found"], + }, + ], + buy_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_sweater"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions(["PROMOTION_TEST"], context) + + expect(result).toEqual([]) + }) + }) }) diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index 1756901800076..ecdc1b5113ef9 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -263,6 +263,8 @@ export default class PromotionModuleService< "application_method", "application_method.target_rules", "application_method.target_rules.values", + "application_method.buy_rules", + "application_method.buy_rules.values", "rules", "rules.values", "campaign", @@ -271,6 +273,10 @@ export default class PromotionModuleService< } ) + const sortedPermissionsToApply = promotions + .filter((p) => promotionCodesToApply.includes(p.code!)) + .sort(ComputeActionUtils.sortByBuyGetType) + const existingPromotionsMap = new Map( promotions.map((promotion) => [promotion.code!, promotion]) ) @@ -306,15 +312,8 @@ export default class PromotionModuleService< } } - for (const promotionCode of promotionCodesToApply) { - const promotion = existingPromotionsMap.get(promotionCode) - - if (!promotion) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Promotion for code (${promotionCode}) not found` - ) - } + for (const promotionToApply of sortedPermissionsToApply) { + const promotion = existingPromotionsMap.get(promotionToApply.code!)! const { application_method: applicationMethod, @@ -334,20 +333,9 @@ export default class PromotionModuleService< continue } - if (applicationMethod.target_type === ApplicationMethodTargetType.ORDER) { - const computedActionsForItems = - ComputeActionUtils.getComputedActionsForOrder( - promotion, - applicationContext, - methodIdPromoValueMap - ) - - computedActions.push(...computedActionsForItems) - } - - if (applicationMethod.target_type === ApplicationMethodTargetType.ITEMS) { + if (promotion.type === PromotionType.BUYGET) { const computedActionsForItems = - ComputeActionUtils.getComputedActionsForItems( + ComputeActionUtils.getComputedActionsForBuyGet( promotion, applicationContext[ApplicationMethodTargetType.ITEMS], methodIdPromoValueMap @@ -356,18 +344,46 @@ export default class PromotionModuleService< computedActions.push(...computedActionsForItems) } - if ( - applicationMethod.target_type === - ApplicationMethodTargetType.SHIPPING_METHODS - ) { - const computedActionsForShippingMethods = - ComputeActionUtils.getComputedActionsForShippingMethods( - promotion, - applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS], - methodIdPromoValueMap - ) + if (promotion.type === PromotionType.STANDARD) { + if ( + applicationMethod.target_type === ApplicationMethodTargetType.ORDER + ) { + const computedActionsForItems = + ComputeActionUtils.getComputedActionsForOrder( + promotion, + applicationContext, + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForItems) + } + + if ( + applicationMethod.target_type === ApplicationMethodTargetType.ITEMS + ) { + const computedActionsForItems = + ComputeActionUtils.getComputedActionsForItems( + promotion, + applicationContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForItems) + } - computedActions.push(...computedActionsForShippingMethods) + if ( + applicationMethod.target_type === + ApplicationMethodTargetType.SHIPPING_METHODS + ) { + const computedActionsForShippingMethods = + ComputeActionUtils.getComputedActionsForShippingMethods( + promotion, + applicationContext[ApplicationMethodTargetType.SHIPPING_METHODS], + methodIdPromoValueMap + ) + + computedActions.push(...computedActionsForShippingMethods) + } } } diff --git a/packages/promotion/src/utils/compute-actions/buy-get.ts b/packages/promotion/src/utils/compute-actions/buy-get.ts new file mode 100644 index 0000000000000..cc6eaa44e114f --- /dev/null +++ b/packages/promotion/src/utils/compute-actions/buy-get.ts @@ -0,0 +1,101 @@ +import { PromotionTypes } from "@medusajs/types" +import { + ApplicationMethodTargetType, + ComputedActions, + MedusaError, + PromotionType, +} from "@medusajs/utils" +import { areRulesValidForContext } from "../validations/promotion-rule" +import { computeActionForBudgetExceeded } from "./usage" + +export function getComputedActionsForBuyGet( + promotion: PromotionTypes.PromotionDTO, + itemsContext: PromotionTypes.ComputeActionContext[ApplicationMethodTargetType.ITEMS], + methodIdPromoValueMap: Map +): PromotionTypes.ComputeActions[] { + const buyRulesMinQuantity = + promotion.application_method?.buy_rules_min_quantity + const applyToQuantity = promotion.application_method?.apply_to_quantity + const buyRules = promotion.application_method?.buy_rules + const targetRules = promotion.application_method?.target_rules + const computedActions: PromotionTypes.ComputeActions[] = [] + + if (!itemsContext) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `"items" should be present as an array in the context to compute actions` + ) + } + + if (!Array.isArray(buyRules) || !Array.isArray(targetRules)) { + return [] + } + + const validQuantity = itemsContext + .filter((item) => areRulesValidForContext(buyRules, item)) + .reduce((acc, next) => acc + next.quantity, 0) + + if ( + !buyRulesMinQuantity || + !applyToQuantity || + buyRulesMinQuantity > validQuantity + ) { + return [] + } + + const validItemsForTargetRules = itemsContext + .filter((item) => areRulesValidForContext(targetRules, item)) + .sort((a, b) => { + return b.unit_price - a.unit_price + }) + + let remainingQtyToApply = applyToQuantity + + for (const method of validItemsForTargetRules) { + const appliedPromoValue = methodIdPromoValueMap.get(method.id) || 0 + const multiplier = Math.min(method.quantity, remainingQtyToApply) + const amount = method.unit_price * multiplier + const newRemainingQtyToApply = remainingQtyToApply - multiplier + + if (newRemainingQtyToApply < 0 || amount <= 0) { + break + } else { + remainingQtyToApply = newRemainingQtyToApply + } + + const budgetExceededAction = computeActionForBudgetExceeded( + promotion, + amount + ) + + if (budgetExceededAction) { + computedActions.push(budgetExceededAction) + + continue + } + + methodIdPromoValueMap.set(method.id, appliedPromoValue + amount) + + computedActions.push({ + action: ComputedActions.ADD_ITEM_ADJUSTMENT, + item_id: method.id, + amount, + code: promotion.code!, + }) + } + + return computedActions +} + +export function sortByBuyGetType(a, b) { + if (a.type === PromotionType.BUYGET && b.type !== PromotionType.BUYGET) { + return -1 + } else if ( + a.type !== PromotionType.BUYGET && + b.type === PromotionType.BUYGET + ) { + return 1 + } else { + return 0 + } +} diff --git a/packages/promotion/src/utils/compute-actions/index.ts b/packages/promotion/src/utils/compute-actions/index.ts index 33d690935a0d4..de8317a075ca0 100644 --- a/packages/promotion/src/utils/compute-actions/index.ts +++ b/packages/promotion/src/utils/compute-actions/index.ts @@ -1,3 +1,4 @@ +export * from "./buy-get" export * from "./items" export * from "./order" export * from "./shipping-methods" From 18ff739a9496fbe028dd04ea62129e0f88f3d621 Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Tue, 30 Jan 2024 12:43:30 +0100 Subject: [PATCH 02/11] feat(customer): admin CRUD endpoints (#6232) **What** - GET /customers/:id - POST /customers/:id - DELETE /customers/:id - POST /customers Including workflows for each. --- .../customer/admin/create-customer.ts | 67 ++++++++++++++++++ .../customer/admin/delete-customer.ts | 65 +++++++++++++++++ .../customer/admin/update-customer.ts | 70 +++++++++++++++++++ packages/core-flows/src/customer/index.ts | 2 + .../src/customer/steps/create-customers.ts | 31 ++++++++ .../src/customer/steps/delete-customers.ts | 30 ++++++++ .../core-flows/src/customer/steps/index.ts | 3 + .../src/customer/steps/update-customers.ts | 59 ++++++++++++++++ .../customer/workflows/create-customers.ts | 13 ++++ .../customer/workflows/delete-customers.ts | 12 ++++ .../src/customer/workflows/index.ts | 3 + .../customer/workflows/update-customers.ts | 22 ++++++ packages/core-flows/src/index.ts | 1 + .../src/migrations/Migration20240124154000.ts | 1 + .../customer/src/services/customer-module.ts | 9 +-- .../src/api-v2/admin/customers/[id]/route.ts | 60 ++++++++++++++++ .../src/api-v2/admin/customers/middlewares.ts | 18 ++--- .../src/api-v2/admin/customers/route.ts | 24 ++++++- .../src/api-v2/admin/customers/validators.ts | 10 +++ packages/types/src/customer/mutations.ts | 21 ++++-- packages/types/src/customer/service.ts | 7 +- 21 files changed, 503 insertions(+), 25 deletions(-) create mode 100644 integration-tests/plugins/__tests__/customer/admin/create-customer.ts create mode 100644 integration-tests/plugins/__tests__/customer/admin/delete-customer.ts create mode 100644 integration-tests/plugins/__tests__/customer/admin/update-customer.ts create mode 100644 packages/core-flows/src/customer/index.ts create mode 100644 packages/core-flows/src/customer/steps/create-customers.ts create mode 100644 packages/core-flows/src/customer/steps/delete-customers.ts create mode 100644 packages/core-flows/src/customer/steps/index.ts create mode 100644 packages/core-flows/src/customer/steps/update-customers.ts create mode 100644 packages/core-flows/src/customer/workflows/create-customers.ts create mode 100644 packages/core-flows/src/customer/workflows/delete-customers.ts create mode 100644 packages/core-flows/src/customer/workflows/index.ts create mode 100644 packages/core-flows/src/customer/workflows/update-customers.ts create mode 100644 packages/medusa/src/api-v2/admin/customers/[id]/route.ts diff --git a/integration-tests/plugins/__tests__/customer/admin/create-customer.ts b/integration-tests/plugins/__tests__/customer/admin/create-customer.ts new file mode 100644 index 0000000000000..1a11e54234933 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/create-customer.ts @@ -0,0 +1,67 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/customers", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should create a customer", async () => { + const api = useApi() as any + const response = await api.post( + `/admin/customers`, + { + first_name: "John", + last_name: "Doe", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.customer).toEqual( + expect.objectContaining({ + id: expect.any(String), + first_name: "John", + last_name: "Doe", + created_by: "admin_user", + }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts b/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts new file mode 100644 index 0000000000000..0d1c032c14539 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts @@ -0,0 +1,65 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("DELETE /admin/customers/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should delete a customer", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + }) + + const api = useApi() as any + const response = await api.delete( + `/admin/customers/${customer.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const deletedCustomer = await customerModuleService.retrieve(customer.id, { + withDeleted: true, + }) + expect(deletedCustomer.deleted_at).toBeTruthy() + }) +}) diff --git a/integration-tests/plugins/__tests__/customer/admin/update-customer.ts b/integration-tests/plugins/__tests__/customer/admin/update-customer.ts new file mode 100644 index 0000000000000..92b8c9da32114 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/update-customer.ts @@ -0,0 +1,70 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/customers/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should update a customer", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + }) + + const api = useApi() as any + const response = await api.post( + `/admin/customers/${customer.id}`, + { + first_name: "Jane", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.customer).toEqual( + expect.objectContaining({ + id: expect.any(String), + first_name: "Jane", + last_name: "Doe", + }) + ) + }) +}) diff --git a/packages/core-flows/src/customer/index.ts b/packages/core-flows/src/customer/index.ts new file mode 100644 index 0000000000000..68de82c9f92da --- /dev/null +++ b/packages/core-flows/src/customer/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" diff --git a/packages/core-flows/src/customer/steps/create-customers.ts b/packages/core-flows/src/customer/steps/create-customers.ts new file mode 100644 index 0000000000000..3310e1e8738ce --- /dev/null +++ b/packages/core-flows/src/customer/steps/create-customers.ts @@ -0,0 +1,31 @@ +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { CreateCustomerDTO, ICustomerModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const createCustomersStepId = "create-customers" +export const createCustomersStep = createStep( + createCustomersStepId, + async (data: CreateCustomerDTO[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const createdCustomers = await service.create(data) + + return new StepResponse( + createdCustomers, + createdCustomers.map((createdCustomers) => createdCustomers.id) + ) + }, + async (createdCustomerIds, { container }) => { + if (!createdCustomerIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.delete(createdCustomerIds) + } +) diff --git a/packages/core-flows/src/customer/steps/delete-customers.ts b/packages/core-flows/src/customer/steps/delete-customers.ts new file mode 100644 index 0000000000000..35b6947c73d1e --- /dev/null +++ b/packages/core-flows/src/customer/steps/delete-customers.ts @@ -0,0 +1,30 @@ +import { ICustomerModuleService } from "@medusajs/types" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +type DeleteCustomerStepInput = string[] + +export const deleteCustomerStepId = "delete-customer" +export const deleteCustomerStep = createStep( + deleteCustomerStepId, + async (ids: DeleteCustomerStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.softDelete(ids) + + return new StepResponse(void 0, ids) + }, + async (prevCustomerIds, { container }) => { + if (!prevCustomerIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.restore(prevCustomerIds) + } +) diff --git a/packages/core-flows/src/customer/steps/index.ts b/packages/core-flows/src/customer/steps/index.ts new file mode 100644 index 0000000000000..6ea82b9cd6882 --- /dev/null +++ b/packages/core-flows/src/customer/steps/index.ts @@ -0,0 +1,3 @@ +export * from "./create-customers" +export * from "./update-customers" +export * from "./delete-customers" diff --git a/packages/core-flows/src/customer/steps/update-customers.ts b/packages/core-flows/src/customer/steps/update-customers.ts new file mode 100644 index 0000000000000..c0e7c90f3b4a9 --- /dev/null +++ b/packages/core-flows/src/customer/steps/update-customers.ts @@ -0,0 +1,59 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + FilterableCustomerProps, + ICustomerModuleService, + CustomerUpdatableFields, +} from "@medusajs/types" +import { + getSelectsAndRelationsFromObjectArray, + promiseAll, +} from "@medusajs/utils" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" + +type UpdateCustomersStepInput = { + selector: FilterableCustomerProps + update: CustomerUpdatableFields +} + +export const updateCustomersStepId = "update-customer" +export const updateCustomersStep = createStep( + updateCustomersStepId, + async (data: UpdateCustomersStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + const prevCustomers = await service.list(data.selector, { + select: selects, + relations, + }) + + const customers = await service.update(data.selector, data.update) + + return new StepResponse(customers, prevCustomers) + }, + async (prevCustomers, { container }) => { + if (!prevCustomers?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await promiseAll( + prevCustomers.map((c) => + service.update(c.id, { + first_name: c.first_name, + last_name: c.last_name, + email: c.email, + phone: c.phone, + metadata: c.metadata, + }) + ) + ) + } +) diff --git a/packages/core-flows/src/customer/workflows/create-customers.ts b/packages/core-flows/src/customer/workflows/create-customers.ts new file mode 100644 index 0000000000000..23e19d33d1d40 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/create-customers.ts @@ -0,0 +1,13 @@ +import { CustomerDTO, CreateCustomerDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createCustomersStep } from "../steps" + +type WorkflowInput = { customersData: CreateCustomerDTO[] } + +export const createCustomersWorkflowId = "create-customers" +export const createCustomersWorkflow = createWorkflow( + createCustomersWorkflowId, + (input: WorkflowData): WorkflowData => { + return createCustomersStep(input.customersData) + } +) diff --git a/packages/core-flows/src/customer/workflows/delete-customers.ts b/packages/core-flows/src/customer/workflows/delete-customers.ts new file mode 100644 index 0000000000000..7603d4daec2bf --- /dev/null +++ b/packages/core-flows/src/customer/workflows/delete-customers.ts @@ -0,0 +1,12 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteCustomerStep } from "../steps" + +type WorkflowInput = { ids: string[] } + +export const deleteCustomersWorkflowId = "delete-customers" +export const deleteCustomersWorkflow = createWorkflow( + deleteCustomersWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteCustomerStep(input.ids) + } +) diff --git a/packages/core-flows/src/customer/workflows/index.ts b/packages/core-flows/src/customer/workflows/index.ts new file mode 100644 index 0000000000000..6ea82b9cd6882 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/index.ts @@ -0,0 +1,3 @@ +export * from "./create-customers" +export * from "./update-customers" +export * from "./delete-customers" diff --git a/packages/core-flows/src/customer/workflows/update-customers.ts b/packages/core-flows/src/customer/workflows/update-customers.ts new file mode 100644 index 0000000000000..478533f305cd4 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/update-customers.ts @@ -0,0 +1,22 @@ +import { + CustomerDTO, + CustomerUpdatableFields, + FilterableCustomerProps, +} from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updateCustomersStep } from "../steps" + +type UpdateCustomersStepInput = { + selector: FilterableCustomerProps + update: CustomerUpdatableFields +} + +type WorkflowInput = UpdateCustomersStepInput + +export const updateCustomersWorkflowId = "update-customers" +export const updateCustomersWorkflow = createWorkflow( + updateCustomersWorkflowId, + (input: WorkflowData): WorkflowData => { + return updateCustomersStep(input) + } +) diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index b6819d03ff15c..0d26e847b65b8 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -2,3 +2,4 @@ export * from "./definition" export * from "./definitions" export * as Handlers from "./handlers" export * from "./promotion" +export * from "./customer" diff --git a/packages/customer/src/migrations/Migration20240124154000.ts b/packages/customer/src/migrations/Migration20240124154000.ts index 443d06f80db9a..c44f78d7f29a0 100644 --- a/packages/customer/src/migrations/Migration20240124154000.ts +++ b/packages/customer/src/migrations/Migration20240124154000.ts @@ -6,6 +6,7 @@ export class Migration20240124154000 extends Migration { this.addSql( 'create table if not exists "customer" ("id" text not null, "company_name" text null, "first_name" text null, "last_name" text null, "email" text null, "phone" text null, "has_account" boolean not null default false, "metadata" jsonb null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, "created_by" text null, constraint "customer_pkey" primary key ("id"));' ) + this.addSql('alter table "customer" alter column "email" drop not null;') this.addSql( 'alter table "customer" add column if not exists "company_name" text null;' ) diff --git a/packages/customer/src/services/customer-module.ts b/packages/customer/src/services/customer-module.ts index 40c682e08a640..a2352f1d67565 100644 --- a/packages/customer/src/services/customer-module.ts +++ b/packages/customer/src/services/customer-module.ts @@ -8,6 +8,7 @@ import { CustomerTypes, SoftDeleteReturn, RestoreReturn, + CustomerUpdatableFields, } from "@medusajs/types" import { @@ -110,24 +111,24 @@ export default class CustomerModuleService implements ICustomerModuleService { update( customerId: string, - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise update( customerIds: string[], - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise update( selector: CustomerTypes.FilterableCustomerProps, - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise @InjectTransactionManager("baseRepository_") async update( idsOrSelector: string | string[] | CustomerTypes.FilterableCustomerProps, - data: Partial, + data: CustomerUpdatableFields, @MedusaContext() sharedContext: Context = {} ) { let updateData: CustomerTypes.UpdateCustomerDTO[] = [] diff --git a/packages/medusa/src/api-v2/admin/customers/[id]/route.ts b/packages/medusa/src/api-v2/admin/customers/[id]/route.ts new file mode 100644 index 0000000000000..fbf8c8cc6e8a5 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customers/[id]/route.ts @@ -0,0 +1,60 @@ +import { + updateCustomersWorkflow, + deleteCustomersWorkflow, +} from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CustomerUpdatableFields, + ICustomerModuleService, +} from "@medusajs/types" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const customerModuleService = req.scope.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const customer = await customerModuleService.retrieve(req.params.id, { + select: req.retrieveConfig.select, + relations: req.retrieveConfig.relations, + }) + + res.status(200).json({ customer }) +} + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const updateCustomers = updateCustomersWorkflow(req.scope) + const { result, errors } = await updateCustomers.run({ + input: { + selector: { id: req.params.id }, + update: req.validatedBody as CustomerUpdatableFields, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ customer: result[0] }) +} + +export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => { + const id = req.params.id + const deleteCustomers = deleteCustomersWorkflow(req.scope) + + const { errors } = await deleteCustomers.run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + id, + object: "customer", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/customers/middlewares.ts b/packages/medusa/src/api-v2/admin/customers/middlewares.ts index 962b1a4529ccf..3d72dcc9873ef 100644 --- a/packages/medusa/src/api-v2/admin/customers/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/customers/middlewares.ts @@ -1,23 +1,14 @@ -import { MedusaV2Flag } from "@medusajs/utils" - -import { - isFeatureFlagEnabled, - transformBody, - transformQuery, -} from "../../../api/middlewares" +import { transformBody, transformQuery } from "../../../api/middlewares" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import * as QueryConfig from "./query-config" import { AdminGetCustomersParams, AdminGetCustomersCustomerParams, + AdminPostCustomersReq, AdminPostCustomersCustomerReq, } from "./validators" export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [ - { - matcher: "/admin/customers*", - middlewares: [isFeatureFlagEnabled(MedusaV2Flag.key)], - }, { method: ["GET"], matcher: "/admin/customers", @@ -28,6 +19,11 @@ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [ ), ], }, + { + method: ["POST"], + matcher: "/admin/customers", + middlewares: [transformBody(AdminPostCustomersReq)], + }, { method: ["GET"], matcher: "/admin/customers/:id", diff --git a/packages/medusa/src/api-v2/admin/customers/route.ts b/packages/medusa/src/api-v2/admin/customers/route.ts index 8be7d305362b7..302d46f430e82 100644 --- a/packages/medusa/src/api-v2/admin/customers/route.ts +++ b/packages/medusa/src/api-v2/admin/customers/route.ts @@ -1,5 +1,6 @@ +import { createCustomersWorkflow } from "@medusajs/core-flows" import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { ICustomerModuleService } from "@medusajs/types" +import { CreateCustomerDTO, ICustomerModuleService } from "@medusajs/types" import { MedusaRequest, MedusaResponse } from "../../../types/routing" export const GET = async (req: MedusaRequest, res: MedusaResponse) => { @@ -39,3 +40,24 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { limit, }) } + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const createCustomers = createCustomersWorkflow(req.scope) + const customersData = [ + { + ...(req.validatedBody as CreateCustomerDTO), + created_by: req.user!.id, + }, + ] + + const { result, errors } = await createCustomers.run({ + input: { customersData }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ customer: result[0] }) +} diff --git a/packages/medusa/src/api-v2/admin/customers/validators.ts b/packages/medusa/src/api-v2/admin/customers/validators.ts index 4f1fda5ca0816..40703d4961ff7 100644 --- a/packages/medusa/src/api-v2/admin/customers/validators.ts +++ b/packages/medusa/src/api-v2/admin/customers/validators.ts @@ -121,6 +121,11 @@ export class AdminPostCustomersReq { @IsString() @IsOptional() email?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + phone?: string } export class AdminPostCustomersCustomerReq { @@ -143,4 +148,9 @@ export class AdminPostCustomersCustomerReq { @IsString() @IsOptional() email?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + phone?: string } diff --git a/packages/types/src/customer/mutations.ts b/packages/types/src/customer/mutations.ts index a2275be9a20c9..1e1d42334e300 100644 --- a/packages/types/src/customer/mutations.ts +++ b/packages/types/src/customer/mutations.ts @@ -48,12 +48,21 @@ export interface CreateCustomerDTO { export interface UpdateCustomerDTO { id: string - company_name?: string - first_name?: string - last_name?: string - email?: string - phone?: string - metadata?: Record + company_name?: string | null + first_name?: string | null + last_name?: string | null + email?: string | null + phone?: string | null + metadata?: Record | null +} + +export interface CustomerUpdatableFields { + company_name?: string | null + first_name?: string | null + last_name?: string | null + email?: string | null + phone?: string | null + metadata?: Record | null } export interface CreateCustomerGroupDTO { diff --git a/packages/types/src/customer/service.ts b/packages/types/src/customer/service.ts index bd45ec1dcd6f5..56383da8a42f8 100644 --- a/packages/types/src/customer/service.ts +++ b/packages/types/src/customer/service.ts @@ -17,6 +17,7 @@ import { CreateCustomerAddressDTO, CreateCustomerDTO, CreateCustomerGroupDTO, + CustomerUpdatableFields, UpdateCustomerAddressDTO, } from "./mutations" @@ -35,17 +36,17 @@ export interface ICustomerModuleService extends IModuleService { update( customerId: string, - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise update( customerIds: string[], - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise update( selector: FilterableCustomerProps, - data: Partial, + data: CustomerUpdatableFields, sharedContext?: Context ): Promise From 374b9b1fee02b2b010ba6b2a1bef303d015ca503 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Tue, 30 Jan 2024 12:52:54 +0100 Subject: [PATCH 03/11] feat(cart): `GET /store/carts/:id` (#6262) --- .../plugins/__tests__/cart/store/get-cart.ts | 71 +++++++++++++++++++ integration-tests/plugins/medusa-config.js | 5 ++ .../CartModuleSetup20240122122952.ts | 2 + packages/medusa/src/api-v2/middlewares.ts | 6 +- .../src/api-v2/store/carts/[id]/route.ts | 30 ++++++++ .../src/api-v2/store/carts/middlewares.ts | 17 +++++ .../src/api-v2/store/carts/query-config.ts | 64 +++++++++++++++++ .../src/api-v2/store/carts/validators.ts | 3 + .../medusa/src/joiner-configs/cart-service.ts | 2 +- packages/medusa/src/joiner-configs/index.ts | 3 +- 10 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 integration-tests/plugins/__tests__/cart/store/get-cart.ts create mode 100644 packages/medusa/src/api-v2/store/carts/[id]/route.ts create mode 100644 packages/medusa/src/api-v2/store/carts/middlewares.ts create mode 100644 packages/medusa/src/api-v2/store/carts/query-config.ts create mode 100644 packages/medusa/src/api-v2/store/carts/validators.ts diff --git a/integration-tests/plugins/__tests__/cart/store/get-cart.ts b/integration-tests/plugins/__tests__/cart/store/get-cart.ts new file mode 100644 index 0000000000000..0ce5e7870e579 --- /dev/null +++ b/integration-tests/plugins/__tests__/cart/store/get-cart.ts @@ -0,0 +1,71 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICartModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } + +describe("GET /store/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let cartModuleService: ICartModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + cartModuleService = appContainer.resolve(ModuleRegistrationName.CART) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should get cart", async () => { + const cart = await cartModuleService.create({ + currency_code: "usd", + items: [ + { + unit_price: 1000, + quantity: 1, + title: "Test item", + }, + ], + }) + + const api = useApi() as any + const response = await api.get(`/store/carts/${cart.id}`) + + expect(response.status).toEqual(200) + expect(response.data.cart).toEqual( + expect.objectContaining({ + id: cart.id, + currency_code: "usd", + items: expect.arrayContaining([ + expect.objectContaining({ + unit_price: 1000, + quantity: 1, + title: "Test item", + }), + ]), + }) + ) + }) +}) diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index 2088ea4544e5c..41dffcbef92be 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -81,5 +81,10 @@ module.exports = { resources: "shared", resolve: "@medusajs/sales-channel", }, + [Modules.CART]: { + scope: "internal", + resources: "shared", + resolve: "@medusajs/cart", + }, }, } diff --git a/packages/cart/src/migrations/CartModuleSetup20240122122952.ts b/packages/cart/src/migrations/CartModuleSetup20240122122952.ts index a81894789b3c6..cea797ef6881d 100644 --- a/packages/cart/src/migrations/CartModuleSetup20240122122952.ts +++ b/packages/cart/src/migrations/CartModuleSetup20240122122952.ts @@ -21,6 +21,8 @@ export class CartModuleSetup20240122122952 extends Migration { ); ALTER TABLE "cart" ADD COLUMN IF NOT EXISTS "currency_code" TEXT NOT NULL; + ALTER TABLE "cart" ALTER COLUMN "region_id" DROP NOT NULL; + ALTER TABLE "cart" ALTER COLUMN "email" DROP NOT NULL; ALTER TABLE "cart" DROP CONSTRAINT IF EXISTS "FK_242205c81c1152fab1b6e848470"; ALTER TABLE "cart" DROP CONSTRAINT IF EXISTS "FK_484c329f4783be4e18e5e2ff090"; diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index 59b5884fed05b..34fabd658f811 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -1,8 +1,9 @@ import { MiddlewaresConfig } from "../loaders/helpers/routing/types" import { adminCampaignRoutesMiddlewares } from "./admin/campaigns/middlewares" -import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" -import { adminCustomerRoutesMiddlewares } from "./admin/customers/middlewares" import { adminCustomerGroupRoutesMiddlewares } from "./admin/customer-groups/middlewares" +import { adminCustomerRoutesMiddlewares } from "./admin/customers/middlewares" +import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" +import { storeCartRoutesMiddlewares } from "./store/carts/middlewares" export const config: MiddlewaresConfig = { routes: [ @@ -10,5 +11,6 @@ export const config: MiddlewaresConfig = { ...adminCustomerRoutesMiddlewares, ...adminPromotionRoutesMiddlewares, ...adminCampaignRoutesMiddlewares, + ...storeCartRoutesMiddlewares, ], } diff --git a/packages/medusa/src/api-v2/store/carts/[id]/route.ts b/packages/medusa/src/api-v2/store/carts/[id]/route.ts new file mode 100644 index 0000000000000..e0bbedacf8d09 --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/[id]/route.ts @@ -0,0 +1,30 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICartModuleService } from "@medusajs/types" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const cartModuleService: ICartModuleService = req.scope.resolve( + ModuleRegistrationName.CART + ) + + // TODO: Replace with remoteQuery + const cart = await cartModuleService.retrieve(req.params.id, { + select: req.retrieveConfig.select, + relations: req.retrieveConfig.relations, + }) + + // const remoteQuery = req.scope.resolve("remoteQuery") + + // const variables = { id: req.params.id } + + // const query = { + // cart: { + // __args: variables, + // ...defaultStoreCartRemoteQueryObject, + // }, + // } + + // const [cart] = await remoteQuery(query) + + res.json({ cart }) +} diff --git a/packages/medusa/src/api-v2/store/carts/middlewares.ts b/packages/medusa/src/api-v2/store/carts/middlewares.ts new file mode 100644 index 0000000000000..214770119181a --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/middlewares.ts @@ -0,0 +1,17 @@ +import { transformQuery } from "../../../api/middlewares" +import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" +import * as QueryConfig from "./query-config" +import { StoreGetCartsCartParams } from "./validators" + +export const storeCartRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["GET"], + matcher: "/store/carts/:id", + middlewares: [ + transformQuery( + StoreGetCartsCartParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, +] diff --git a/packages/medusa/src/api-v2/store/carts/query-config.ts b/packages/medusa/src/api-v2/store/carts/query-config.ts new file mode 100644 index 0000000000000..a77526002a5c6 --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/query-config.ts @@ -0,0 +1,64 @@ +export const defaultStoreCartFields = [ + "id", + "currency_code", + "email", + "created_at", + "updated_at", + "deleted_at", +] + +export const defaultStoreCartRelations = [ + "items", + "shipping_address", + "billing_address", + "shipping_methods", +] + +export const retrieveTransformQueryConfig = { + defaultFields: defaultStoreCartFields, + defaultRelations: defaultStoreCartRelations, + isList: false, +} + +export const defaultStoreCartRemoteQueryObject = { + fields: defaultStoreCartFields, + line_items: { + fields: [ + "id", + "created_at", + "updated_at", + "deleted_at", + "title", + "quantity", + "unit_price", + ], + }, + shipping_address: { + fields: [ + "id", + "first_name", + "last_name", + "address_1", + "address_2", + "city", + "postal_code", + "country_code", + "region_code", + "phone", + ], + }, + billing_address: { + fields: [ + "id", + "first_name", + "last_name", + "address_1", + "address_2", + "city", + "postal_code", + "country_code", + "region_code", + "phone", + ], + }, +} diff --git a/packages/medusa/src/api-v2/store/carts/validators.ts b/packages/medusa/src/api-v2/store/carts/validators.ts new file mode 100644 index 0000000000000..3dff03e7e899b --- /dev/null +++ b/packages/medusa/src/api-v2/store/carts/validators.ts @@ -0,0 +1,3 @@ +import { FindParams } from "../../../types/common" + +export class StoreGetCartsCartParams extends FindParams {} diff --git a/packages/medusa/src/joiner-configs/cart-service.ts b/packages/medusa/src/joiner-configs/cart-service.ts index 878cfe2cdd3af..f236208ebfe6e 100644 --- a/packages/medusa/src/joiner-configs/cart-service.ts +++ b/packages/medusa/src/joiner-configs/cart-service.ts @@ -4,7 +4,7 @@ import { ModuleJoinerConfig } from "@medusajs/types" import { Cart } from "../models" export default { - serviceName: Modules.CART, + serviceName: "cartService", primaryKeys: ["id"], linkableKeys: { cart_id: "Cart" }, alias: { diff --git a/packages/medusa/src/joiner-configs/index.ts b/packages/medusa/src/joiner-configs/index.ts index fb21643b5d92d..4fccd04161ad5 100644 --- a/packages/medusa/src/joiner-configs/index.ts +++ b/packages/medusa/src/joiner-configs/index.ts @@ -1,5 +1,6 @@ export * as cart from "./cart-service" export * as customer from "./customer-service" +export * as publishableApiKey from "./publishable-api-key-service" export * as region from "./region-service" export * as shippingProfile from "./shipping-profile-service" -export * as publishableApiKey from "./publishable-api-key-service" + From 7d5a6f8b001e5da1405a931adfb7fcc5a090ec3a Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Tue, 30 Jan 2024 20:23:20 +0800 Subject: [PATCH 04/11] feat(auth, medusa): Initial auth module middleware (#6271) note: This is an initial implementation Co-authored-by: Sebastian Rindom <7554214+srindom@users.noreply.github.com> --- packages/auth/src/services/auth-module.ts | 109 +++++++++++++----- packages/medusa/src/types/routing.ts | 1 + .../src/utils/authenticate-middleware.ts | 79 +++++++++++++ packages/types/src/auth/service.ts | 14 +++ 4 files changed, 171 insertions(+), 32 deletions(-) create mode 100644 packages/medusa/src/utils/authenticate-middleware.ts diff --git a/packages/auth/src/services/auth-module.ts b/packages/auth/src/services/auth-module.ts index 2add4cf3248d3..5d6be8fbcb718 100644 --- a/packages/auth/src/services/auth-module.ts +++ b/packages/auth/src/services/auth-module.ts @@ -1,3 +1,5 @@ +import jwt from "jsonwebtoken" + import { AuthenticationInput, AuthenticationResponse, @@ -8,6 +10,7 @@ import { InternalModuleDeclaration, MedusaContainer, ModuleJoinerConfig, + JWTGenerationOptions, } from "@medusajs/types" import { AuthProvider, AuthUser } from "@models" @@ -33,6 +36,15 @@ import { } from "@medusajs/types" import { ServiceTypes } from "@types" +type AuthModuleOptions = { + jwt_secret: string +} + +type AuthJWTPayload = { + id: string + scope: string +} + type InjectedDependencies = { baseRepository: DAL.RepositoryService authUserService: AuthUserService @@ -57,6 +69,7 @@ export default class AuthModuleService< protected authUserService_: AuthUserService protected authProviderService_: AuthProviderService + protected options_: AuthModuleOptions constructor( { @@ -64,12 +77,14 @@ export default class AuthModuleService< authProviderService, baseRepository, }: InjectedDependencies, + options: AuthModuleOptions, protected readonly moduleDeclaration: InternalModuleDeclaration ) { this.__container__ = arguments[0] this.baseRepository_ = baseRepository this.authUserService_ = authUserService this.authProviderService_ = authProviderService + this.options_ = options } async retrieveAuthProvider( @@ -100,9 +115,10 @@ export default class AuthModuleService< sharedContext ) - return await this.baseRepository_.serialize< - AuthTypes.AuthProviderDTO[] - >(authProviders, { populate: true }) + return await this.baseRepository_.serialize( + authProviders, + { populate: true } + ) } @InjectManager("baseRepository_") @@ -118,13 +134,54 @@ export default class AuthModuleService< ) return [ - await this.baseRepository_.serialize< - AuthTypes.AuthProviderDTO[] - >(authProviders, { populate: true }), + await this.baseRepository_.serialize( + authProviders, + { populate: true } + ), count, ] } + async generateJwtToken( + authUserId: string, + scope: string, + options: JWTGenerationOptions = {} + ): Promise { + const authUser = await this.authUserService_.retrieve(authUserId) + return jwt.sign({ id: authUser.id, scope }, this.options_.jwt_secret, { + expiresIn: options.expiresIn || "1d", + }) + } + + async retrieveAuthUserFromJwtToken( + token: string, + scope: string + ): Promise { + let decoded: AuthJWTPayload + try { + const verifiedToken = jwt.verify(token, this.options_.jwt_secret) + decoded = verifiedToken as AuthJWTPayload + } catch (err) { + throw new MedusaError( + MedusaError.Types.UNAUTHORIZED, + "The provided JWT token is invalid" + ) + } + + if (decoded.scope !== scope) { + throw new MedusaError( + MedusaError.Types.UNAUTHORIZED, + "The provided JWT token is invalid" + ) + } + + const authUser = await this.authUserService_.retrieve(decoded.id) + return await this.baseRepository_.serialize( + authUser, + { populate: true } + ) + } + async createAuthProvider( data: CreateAuthProviderDTO[], sharedContext?: Context @@ -139,9 +196,7 @@ export default class AuthModuleService< async createAuthProvider( data: CreateAuthProviderDTO | CreateAuthProviderDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise< - AuthTypes.AuthProviderDTO | AuthTypes.AuthProviderDTO[] - > { + ): Promise { const input = Array.isArray(data) ? data : [data] const providers = await this.createAuthProviders_(input, sharedContext) @@ -174,13 +229,9 @@ export default class AuthModuleService< @InjectManager("baseRepository_") async updateAuthProvider( - data: - | AuthTypes.UpdateAuthProviderDTO[] - | AuthTypes.UpdateAuthProviderDTO, + data: AuthTypes.UpdateAuthProviderDTO[] | AuthTypes.UpdateAuthProviderDTO, @MedusaContext() sharedContext: Context = {} - ): Promise< - AuthTypes.AuthProviderDTO | AuthTypes.AuthProviderDTO[] - > { + ): Promise { const input = Array.isArray(data) ? data : [data] const providers = await this.updateAuthProvider_(input, sharedContext) @@ -241,11 +292,12 @@ export default class AuthModuleService< sharedContext ) - return await this.baseRepository_.serialize< - AuthTypes.AuthUserDTO[] - >(authUsers, { - populate: true, - }) + return await this.baseRepository_.serialize( + authUsers, + { + populate: true, + } + ) } @InjectManager("baseRepository_") @@ -261,12 +313,9 @@ export default class AuthModuleService< ) return [ - await this.baseRepository_.serialize( - authUsers, - { - populate: true, - } - ), + await this.baseRepository_.serialize(authUsers, { + populate: true, + }), count, ] } @@ -284,9 +333,7 @@ export default class AuthModuleService< async createAuthUser( data: CreateAuthUserDTO[] | CreateAuthUserDTO, @MedusaContext() sharedContext: Context = {} - ): Promise< - AuthTypes.AuthUserDTO | AuthTypes.AuthUserDTO[] - > { + ): Promise { const input = Array.isArray(data) ? data : [data] const authUsers = await this.createAuthUsers_(input, sharedContext) @@ -321,9 +368,7 @@ export default class AuthModuleService< async updateAuthUser( data: UpdateAuthUserDTO | UpdateAuthUserDTO[], @MedusaContext() sharedContext: Context = {} - ): Promise< - AuthTypes.AuthUserDTO | AuthTypes.AuthUserDTO[] - > { + ): Promise { const input = Array.isArray(data) ? data : [data] const updatedUsers = await this.updateAuthUsers_(input, sharedContext) diff --git a/packages/medusa/src/types/routing.ts b/packages/medusa/src/types/routing.ts index 54b0051f0b1d3..a524e8a7c188c 100644 --- a/packages/medusa/src/types/routing.ts +++ b/packages/medusa/src/types/routing.ts @@ -6,6 +6,7 @@ import type { MedusaContainer } from "./global" export interface MedusaRequest extends Request { user?: (User | Customer) & { customer_id?: string; userId?: string } scope: MedusaContainer + auth_user?: { id: string; app_metadata: Record; scope: string } } export type MedusaResponse = Response diff --git a/packages/medusa/src/utils/authenticate-middleware.ts b/packages/medusa/src/utils/authenticate-middleware.ts new file mode 100644 index 0000000000000..0417017ef4ce3 --- /dev/null +++ b/packages/medusa/src/utils/authenticate-middleware.ts @@ -0,0 +1,79 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { AuthUserDTO, IAuthModuleService } from "@medusajs/types" +import { NextFunction, RequestHandler } from "express" +import { MedusaRequest, MedusaResponse } from "../types/routing" + +const SESSION_AUTH = "session" +const BEARER_AUTH = "bearer" + +type MedusaSession = { + auth: { + [authScope: string]: { + user_id: string + } + } +} + +type AuthType = "session" | "bearer" + +export default ( + authScope: string, + authType: AuthType | AuthType[], + options: { allowUnauthenticated?: boolean } = {} +): RequestHandler => { + return async ( + req: MedusaRequest, + res: MedusaResponse, + next: NextFunction + ): Promise => { + const authTypes = Array.isArray(authType) ? authType : [authType] + const authModule = req.scope.resolve( + ModuleRegistrationName.AUTH + ) + + // @ts-ignore + const session: MedusaSession = req.session || {} + + let authUser: AuthUserDTO | null = null + if (authTypes.includes(SESSION_AUTH)) { + if (session.auth && session.auth[authScope]) { + authUser = await authModule + .retrieveAuthUser(session.auth[authScope].user_id) + .catch(() => null) + } + } + + if (authTypes.includes(BEARER_AUTH)) { + const authHeader = req.headers.authorization + if (authHeader) { + const re = /(\S+)\s+(\S+)/ + const matches = authHeader.match(re) + + if (matches) { + const tokenType = matches[1] + const token = matches[2] + if (tokenType.toLowerCase() === "bearer") { + authUser = await authModule + .retrieveAuthUserFromJwtToken(token, authScope) + .catch(() => null) + } + } + } + } + + if (authUser) { + req.auth_user = { + id: authUser.id, + app_metadata: authUser.app_metadata, + scope: authScope, + } + return next() + } + + if (options.allowUnauthenticated) { + return next() + } + + res.status(401).json({ message: "Unauthorized" }) + } +} diff --git a/packages/types/src/auth/service.ts b/packages/types/src/auth/service.ts index 4f4bed7ecc6c1..88b72362db24b 100644 --- a/packages/types/src/auth/service.ts +++ b/packages/types/src/auth/service.ts @@ -15,6 +15,10 @@ import { Context } from "../shared-context" import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" +export type JWTGenerationOptions = { + expiresIn?: string | number +} + export interface IAuthModuleService extends IModuleService { authenticate( provider: string, @@ -72,6 +76,16 @@ export interface IAuthModuleService extends IModuleService { sharedContext?: Context ): Promise + generateJwtToken( + authUserId: string, + scope: string, + options?: JWTGenerationOptions + ): Promise + retrieveAuthUserFromJwtToken( + token: string, + scope: string + ): Promise + listAuthUsers( filters?: FilterableAuthProviderProps, config?: FindConfig, From 1100c21c63cd0427359fd25ec0d498b764915487 Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Tue, 30 Jan 2024 17:20:06 +0100 Subject: [PATCH 05/11] feat(dashboard): Users domain (#6212) --- .../public/locales/en/translation.json | 24 +- .../common/action-menu/action-menu.tsx | 16 +- .../empty-table-content.tsx | 27 +- .../router-provider/router-provider.tsx | 4 + .../profile-general-section.tsx | 8 + .../user-general-section.tsx | 34 +- .../src/routes/users/user-detail/index.ts | 1 + .../src/routes/users/user-detail/loader.ts | 21 + .../routes/users/user-detail/user-detail.tsx | 9 +- .../edit-user-form/edit-user-form.tsx | 108 +++++ .../components/edit-user-form/index.ts | 1 + .../src/routes/users/user-edit/user-edit.tsx | 33 +- .../invite-user-form/invite-user-form.tsx | 436 ++++++++++++++++++ .../routes/users/user-invite/user-invite.tsx | 7 +- .../user-list-table/user-list-table.tsx | 251 ++++++---- .../src/routes/users/user-list/user-list.tsx | 2 + 16 files changed, 864 insertions(+), 118 deletions(-) create mode 100644 packages/admin-next/dashboard/src/routes/users/user-detail/loader.ts create mode 100644 packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx create mode 100644 packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/index.ts create mode 100644 packages/admin-next/dashboard/src/routes/users/user-invite/components/invite-user-form/invite-user-form.tsx diff --git a/packages/admin-next/dashboard/public/locales/en/translation.json b/packages/admin-next/dashboard/public/locales/en/translation.json index 8d0c34af3155d..7e8bee990e87d 100644 --- a/packages/admin-next/dashboard/public/locales/en/translation.json +++ b/packages/admin-next/dashboard/public/locales/en/translation.json @@ -4,9 +4,11 @@ "ascending": "Ascending", "descending": "Descending", "cancel": "Cancel", + "close": "Close", "save": "Save", "create": "Create", "delete": "Delete", + "invite": "Invite", "edit": "Edit", "confirm": "Confirm", "add": "Add", @@ -28,6 +30,7 @@ "enabled": "Enabled", "disabled": "Disabled", "active": "Active", + "revoke": "Revoke", "revoked": "Revoked", "remove": "Remove", "admin": "Admin", @@ -126,7 +129,22 @@ }, "users": { "domain": "Users", - "role": "Role", + "editUser": "Edit User", + "inviteUser": "Invite User", + "inviteUserHint": "Invite a new user to your store.", + "sendInvite": "Send invite", + "pendingInvites": "Pending Invites", + "revokeInviteWarning": "You are about to revoke the invite for {{email}}. This action cannot be undone.", + "resendInvite": "Resend invite", + "copyInviteLink": "Copy invite link", + "expiredOnDate": "Expired on {{date}}", + "validFromUntil": "Valid from <0>{{from}} - <1>{{until}}", + "acceptedOnDate": "Accepted on {{date}}", + "inviteStatus": { + "accepted": "Accepted", + "pending": "Pending", + "expired": "Expired" + }, "roles": { "admin": "Admin", "developer": "Developer", @@ -244,6 +262,8 @@ "account": "Account", "total": "Total", "created": "Created", - "key": "Key" + "key": "Key", + "role": "Role", + "sent": "Sent" } } diff --git a/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx b/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx index 57949865be929..1a621ae89cfb5 100644 --- a/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx +++ b/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx @@ -6,6 +6,7 @@ import { Link } from "react-router-dom" type Action = { icon: ReactNode label: string + disabled?: boolean } & ( | { to: string @@ -47,12 +48,13 @@ export const ActionMenu = ({ groups }: ActionMenuProps) => { if (action.onClick) { return ( { e.stopPropagation() action.onClick() }} - className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2" + className="[&_svg]:text-ui-fg-subtle flex items-center gap-x-2 disabled:opacity-50 disabled:cursor-not-allowed" > {action.icon} {action.label} @@ -62,12 +64,16 @@ export const ActionMenu = ({ groups }: ActionMenuProps) => { return (
- e.stopPropagation()}> - + + e.stopPropagation()}> {action.icon} {action.label} - - + +
) })} diff --git a/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx b/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx index 858d344311392..320df80867778 100644 --- a/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx +++ b/packages/admin-next/dashboard/src/components/common/empty-table-content/empty-table-content.tsx @@ -1,18 +1,24 @@ import { ExclamationCircle, MagnifyingGlass } from "@medusajs/icons" -import { Button, Text } from "@medusajs/ui" +import { Button, Text, clx } from "@medusajs/ui" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" type NoResultsProps = { title?: string message?: string + className?: string } -export const NoResults = ({ title, message }: NoResultsProps) => { +export const NoResults = ({ title, message, className }: NoResultsProps) => { const { t } = useTranslation() return ( -
+
@@ -33,13 +39,24 @@ type NoRecordsProps = { to: string label: string } + className?: string } -export const NoRecords = ({ title, message, action }: NoRecordsProps) => { +export const NoRecords = ({ + title, + message, + action, + className, +}: NoRecordsProps) => { const { t } = useTranslation() return ( -
+
diff --git a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx index c1ccf72385b13..dbdb2d99dda0b 100644 --- a/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx +++ b/packages/admin-next/dashboard/src/providers/router-provider/router-provider.tsx @@ -6,6 +6,7 @@ import type { AdminPublishableApiKeysRes, AdminRegionsRes, AdminSalesChannelsRes, + AdminUserRes, } from "@medusajs/medusa" import { Outlet, @@ -439,6 +440,9 @@ const router = createBrowserRouter([ { path: ":id", lazy: () => import("../../routes/users/user-detail"), + handle: { + crumb: (data: AdminUserRes) => data.user.email, + }, children: [ { path: "edit", diff --git a/packages/admin-next/dashboard/src/routes/profile/profile-detail/components/profile-general-section/profile-general-section.tsx b/packages/admin-next/dashboard/src/routes/profile/profile-detail/components/profile-general-section/profile-general-section.tsx index 49ef382d96fe5..b69b7aaf30e2c 100644 --- a/packages/admin-next/dashboard/src/routes/profile/profile-detail/components/profile-general-section/profile-general-section.tsx +++ b/packages/admin-next/dashboard/src/routes/profile/profile-detail/components/profile-general-section/profile-general-section.tsx @@ -41,6 +41,14 @@ export const ProfileGeneralSection = ({ user }: ProfileGeneralSectionProps) => { {user.email}
+
+ + {t("fields.role")} + + + {t(`users.roles.${user.role}`)} + +
{t("profile.language")} diff --git a/packages/admin-next/dashboard/src/routes/users/user-detail/components/user-general-section/user-general-section.tsx b/packages/admin-next/dashboard/src/routes/users/user-detail/components/user-general-section/user-general-section.tsx index 90ab2cefb1f4a..7d4234ec03d5d 100644 --- a/packages/admin-next/dashboard/src/routes/users/user-detail/components/user-general-section/user-general-section.tsx +++ b/packages/admin-next/dashboard/src/routes/users/user-detail/components/user-general-section/user-general-section.tsx @@ -1,5 +1,5 @@ import { User } from "@medusajs/medusa" -import { Button, Container, Heading, Text } from "@medusajs/ui" +import { Button, Container, Heading, Text, clx } from "@medusajs/ui" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" @@ -9,35 +9,39 @@ type UserGeneralSection = { export const UserGeneralSection = ({ user }: UserGeneralSection) => { const { t } = useTranslation() + + const name = [user.first_name, user.last_name].filter(Boolean).join(" ") + return ( - +
-
- {t("profile.domain")} - - {t("profile.manageYourProfileDetails")} - -
+ {user.email}
-
+
{t("fields.name")} - - {user.first_name} {user.last_name} + + {name ?? "-"}
-
+
- {t("fields.email")} + {t("fields.role")} - {user.email} + {t(`users.roles.${user.role}`)}
diff --git a/packages/admin-next/dashboard/src/routes/users/user-detail/index.ts b/packages/admin-next/dashboard/src/routes/users/user-detail/index.ts index 4f2ddfc43227c..d91450c4f035f 100644 --- a/packages/admin-next/dashboard/src/routes/users/user-detail/index.ts +++ b/packages/admin-next/dashboard/src/routes/users/user-detail/index.ts @@ -1 +1,2 @@ +export { userLoader as loader } from "./loader" export { UserDetail as Component } from "./user-detail" diff --git a/packages/admin-next/dashboard/src/routes/users/user-detail/loader.ts b/packages/admin-next/dashboard/src/routes/users/user-detail/loader.ts new file mode 100644 index 0000000000000..1dbeb58ba85a8 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/users/user-detail/loader.ts @@ -0,0 +1,21 @@ +import { AdminUserRes } from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { adminProductKeys } from "medusa-react" +import { LoaderFunctionArgs } from "react-router-dom" + +import { medusa, queryClient } from "../../../lib/medusa" + +const userDetailQuery = (id: string) => ({ + queryKey: adminProductKeys.detail(id), + queryFn: async () => medusa.admin.users.retrieve(id), +}) + +export const userLoader = async ({ params }: LoaderFunctionArgs) => { + const id = params.id + const query = userDetailQuery(id!) + + return ( + queryClient.getQueryData>(query.queryKey) ?? + (await queryClient.fetchQuery(query)) + ) +} diff --git a/packages/admin-next/dashboard/src/routes/users/user-detail/user-detail.tsx b/packages/admin-next/dashboard/src/routes/users/user-detail/user-detail.tsx index eadcfc8eb7bda..460d6ba81f335 100644 --- a/packages/admin-next/dashboard/src/routes/users/user-detail/user-detail.tsx +++ b/packages/admin-next/dashboard/src/routes/users/user-detail/user-detail.tsx @@ -1,11 +1,16 @@ import { useAdminUser } from "medusa-react" -import { Outlet, json, useParams } from "react-router-dom" +import { Outlet, json, useLoaderData, useParams } from "react-router-dom" import { JsonViewSection } from "../../../components/common/json-view-section" import { UserGeneralSection } from "./components/user-general-section" +import { userLoader } from "./loader" export const UserDetail = () => { + const initialData = useLoaderData() as Awaited> + const { id } = useParams() - const { user, isLoading, isError, error } = useAdminUser(id!) + const { user, isLoading, isError, error } = useAdminUser(id!, { + initialData, + }) if (isLoading) { return
Loading...
diff --git a/packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx b/packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx new file mode 100644 index 0000000000000..66e2ef13478f2 --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/edit-user-form.tsx @@ -0,0 +1,108 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { User } from "@medusajs/medusa" +import { Button, Drawer, Input } from "@medusajs/ui" +import { useAdminUpdateUser } from "medusa-react" +import { useEffect } from "react" +import { useForm } from "react-hook-form" +import { useTranslation } from "react-i18next" +import * as zod from "zod" +import { Form } from "../../../../../components/common/form" + +type EditUserFormProps = { + user: Omit + subscribe: (state: boolean) => void + onSuccessfulSubmit: () => void +} + +const EditUserFormSchema = zod.object({ + first_name: zod.string().optional(), + last_name: zod.string().optional(), +}) + +export const EditUserForm = ({ + user, + subscribe, + onSuccessfulSubmit, +}: EditUserFormProps) => { + const form = useForm>({ + defaultValues: { + first_name: user.first_name || "", + last_name: user.last_name || "", + }, + resolver: zodResolver(EditUserFormSchema), + }) + + const { + formState: { isDirty }, + } = form + + useEffect(() => { + subscribe(isDirty) + }, [isDirty]) + + const { t } = useTranslation() + + const { mutateAsync, isLoading } = useAdminUpdateUser(user.id) + + const handleSubmit = form.handleSubmit(async (values) => { + await mutateAsync(values, { + onSuccess: () => { + onSuccessfulSubmit() + }, + }) + }) + + return ( +
+ + + { + return ( + + {t("fields.firstName")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.lastName")} + + + + + + ) + }} + /> + + +
+ + + + +
+
+
+ + ) +} diff --git a/packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/index.ts b/packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/index.ts new file mode 100644 index 0000000000000..1ea01b95e37ab --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/users/user-edit/components/edit-user-form/index.ts @@ -0,0 +1 @@ +export * from "./edit-user-form" diff --git a/packages/admin-next/dashboard/src/routes/users/user-edit/user-edit.tsx b/packages/admin-next/dashboard/src/routes/users/user-edit/user-edit.tsx index aa9830cb9451a..1ff3bed6b8a32 100644 --- a/packages/admin-next/dashboard/src/routes/users/user-edit/user-edit.tsx +++ b/packages/admin-next/dashboard/src/routes/users/user-edit/user-edit.tsx @@ -1,12 +1,39 @@ -import { Drawer } from "@medusajs/ui" +import { Drawer, Heading } from "@medusajs/ui" +import { useAdminUser } from "medusa-react" +import { useTranslation } from "react-i18next" +import { useParams } from "react-router-dom" import { useRouteModalState } from "../../../hooks/use-route-modal-state" +import { EditUserForm } from "./components/edit-user-form" export const UserEdit = () => { - const [open, onOpenChange] = useRouteModalState() + const [open, onOpenChange, subscribe] = useRouteModalState() + + const { t } = useTranslation() + const { id } = useParams() + const { user, isLoading, isError, error } = useAdminUser(id!) + + const handleSuccessfulSubmit = () => { + onOpenChange(false, true) + } + + if (isError) { + throw error + } return ( - + + + {t("users.editUser")} + + {!isLoading && user && ( + + )} + ) } diff --git a/packages/admin-next/dashboard/src/routes/users/user-invite/components/invite-user-form/invite-user-form.tsx b/packages/admin-next/dashboard/src/routes/users/user-invite/components/invite-user-form/invite-user-form.tsx new file mode 100644 index 0000000000000..7dd029d729a9d --- /dev/null +++ b/packages/admin-next/dashboard/src/routes/users/user-invite/components/invite-user-form/invite-user-form.tsx @@ -0,0 +1,436 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { ArrowPath, Link, XCircle } from "@medusajs/icons" +import { Invite } from "@medusajs/medusa" +import { + Button, + Container, + FocusModal, + Heading, + Input, + Select, + StatusBadge, + Table, + Text, + Tooltip, + clx, + usePrompt, +} from "@medusajs/ui" +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table" +import { format } from "date-fns" +import { + useAdminCreateInvite, + useAdminDeleteInvite, + useAdminInvites, + useAdminResendInvite, + useAdminStore, +} from "medusa-react" +import { useEffect, useMemo } from "react" +import { useForm } from "react-hook-form" +import { Trans, useTranslation } from "react-i18next" +import * as zod from "zod" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { NoRecords } from "../../../../../components/common/empty-table-content" +import { Form } from "../../../../../components/common/form" +import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination" + +type InviteUserFormProps = { + subscribe: (state: boolean) => void +} + +enum UserRole { + MEMBER = "member", + DEVELOPER = "developer", + ADMIN = "admin", +} + +const InviteUserSchema = zod.object({ + user: zod.string().email(), + role: zod.nativeEnum(UserRole), +}) + +const PAGE_SIZE = 10 + +export const InviteUserForm = ({ subscribe }: InviteUserFormProps) => { + const form = useForm>({ + defaultValues: { + user: "", + role: UserRole.MEMBER, + }, + resolver: zodResolver(InviteUserSchema), + }) + const { mutateAsync, isLoading: isMutating } = useAdminCreateInvite() + + const { + formState: { isDirty }, + } = form + + useEffect(() => { + subscribe(isDirty) + }, [isDirty]) + + const { invites, isLoading, isError, error } = useAdminInvites() + const count = invites?.length ?? 0 + + const noRecords = !isLoading && count === 0 + + const columns = useColumns() + + const table = useReactTable({ + data: invites ?? [], + columns, + pageCount: Math.ceil(count / PAGE_SIZE), + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + const { t } = useTranslation() + + const handleSubmit = form.handleSubmit(async (values) => { + await mutateAsync( + { + role: values.role, + user: values.user, + }, + { + onSuccess: () => { + form.reset() + }, + } + ) + }) + + if (isError) { + throw error + } + + return ( +
+ + +
+ + + +
+
+ +
+
+
+ {t("users.inviteUser")} + + {t("users.inviteUserHint")} + +
+
+
+ { + return ( + + {t("fields.email")} + + + + + + ) + }} + /> + { + return ( + + {t("fields.role")} + + + + + + ) + }} + /> +
+
+ +
+
+
+ {t("users.pendingInvites")} + + {!noRecords ? ( +
+ + + {table.getHeaderGroups().map((headerGroup) => { + return ( + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ) + })} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + +
+ +
+ ) : ( + + )} +
+
+
+
+
+
+ + ) +} + +const InviteActions = ({ invite }: { invite: Invite }) => { + const { mutateAsync: revokeAsync } = useAdminDeleteInvite(invite.id) + const { mutateAsync: resendAsync } = useAdminResendInvite(invite.id) + const { store, isLoading, isError, error } = useAdminStore() + const prompt = usePrompt() + const { t } = useTranslation() + + const handleRevoke = async () => { + const res = await prompt({ + title: t("general.areYouSure"), + description: t("users.revokeInviteWarning", { + email: invite.user_email, + }), + cancelText: t("general.cancel"), + confirmText: t("general.confirm"), + }) + + if (!res) { + return + } + + await revokeAsync() + } + + const handleResend = async () => { + await resendAsync() + } + + const handleCopyInviteLink = () => { + const template = store?.invite_link_template + + if (!template) { + return + } + + const link = template.replace("{invite_token}", invite.token) + navigator.clipboard.writeText(link) + } + + if (isError) { + throw error + } + + return ( + , + label: t("users.copyInviteLink"), + disabled: isLoading || !store?.invite_link_template, + onClick: handleCopyInviteLink, + }, + { + icon: , + label: t("users.resendInvite"), + onClick: handleResend, + }, + ], + }, + { + actions: [ + { + icon: , + label: t("general.revoke"), + onClick: handleRevoke, + }, + ], + }, + ]} + /> + ) +} + +const columnHelper = createColumnHelper() + +const useColumns = () => { + const { t } = useTranslation() + + return useMemo( + () => [ + columnHelper.accessor("user_email", { + header: t("fields.email"), + cell: ({ getValue }) => { + return getValue() + }, + }), + columnHelper.accessor("role", { + header: t("fields.role"), + cell: ({ getValue }) => { + return t(`users.roles.${getValue()}`) + }, + }), + columnHelper.accessor("accepted", { + header: t("fields.status"), + cell: ({ getValue, row }) => { + const accepted = getValue() + const expired = new Date(row.original.expires_at) < new Date() + + if (accepted) { + return ( + + + {t("users.inviteStatus.accepted")} + + + ) + } + + if (expired) { + return ( + + + {t("users.inviteStatus.expired")} + + + ) + } + + return ( + , + , + ]} + values={{ + from: format( + new Date(row.original.created_at), + "dd MMM, yyyy" + ), + until: format( + new Date(row.original.expires_at), + "dd MMM, yyyy" + ), + }} + /> + } + > + + {t("users.inviteStatus.pending")} + + + ) + }, + }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => , + }), + ], + [t] + ) +} diff --git a/packages/admin-next/dashboard/src/routes/users/user-invite/user-invite.tsx b/packages/admin-next/dashboard/src/routes/users/user-invite/user-invite.tsx index cb4bd877a0ce5..d8f0f2d4854ac 100644 --- a/packages/admin-next/dashboard/src/routes/users/user-invite/user-invite.tsx +++ b/packages/admin-next/dashboard/src/routes/users/user-invite/user-invite.tsx @@ -1,12 +1,15 @@ import { FocusModal } from "@medusajs/ui" import { useRouteModalState } from "../../../hooks/use-route-modal-state" +import { InviteUserForm } from "./components/invite-user-form/invite-user-form" export const UserInvite = () => { - const [open, onOpenChange] = useRouteModalState() + const [open, onOpenChange, subscribe] = useRouteModalState() return ( - + + + ) } diff --git a/packages/admin-next/dashboard/src/routes/users/user-list/components/user-list-table/user-list-table.tsx b/packages/admin-next/dashboard/src/routes/users/user-list/components/user-list-table/user-list-table.tsx index 19647c38237aa..8b4e191f7ac95 100644 --- a/packages/admin-next/dashboard/src/routes/users/user-list/components/user-list-table/user-list-table.tsx +++ b/packages/admin-next/dashboard/src/routes/users/user-list/components/user-list-table/user-list-table.tsx @@ -1,7 +1,8 @@ +import { PencilSquare } from "@medusajs/icons" import { User } from "@medusajs/medusa" -import { Checkbox, Container, Heading, Table, clx } from "@medusajs/ui" +import { Button, Container, Heading, Table, clx } from "@medusajs/ui" import { - RowSelectionState, + PaginationState, createColumnHelper, flexRender, getCoreRowModel, @@ -10,28 +11,67 @@ import { import { useAdminUsers } from "medusa-react" import { useMemo, useState } from "react" import { useTranslation } from "react-i18next" -import { useNavigate } from "react-router-dom" +import { Link, useNavigate } from "react-router-dom" +import { ActionMenu } from "../../../../../components/common/action-menu" +import { + NoRecords, + NoResults, +} from "../../../../../components/common/empty-table-content" +import { OrderBy } from "../../../../../components/filtering/order-by" +import { Query } from "../../../../../components/filtering/query" +import { LocalizedTablePagination } from "../../../../../components/localization/localized-table-pagination" +import { useQueryParams } from "../../../../../hooks/use-query-params" + +const PAGE_SIZE = 50 export const UserListTable = () => { - const [rowSelection, setRowSelection] = useState({}) + const [{ pageIndex, pageSize }, setPagination] = useState({ + pageIndex: 0, + pageSize: PAGE_SIZE, + }) + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + }), + [pageIndex, pageSize] + ) - const { users, isLoading, isError, error } = useAdminUsers() + const params = useQueryParams(["q", "order"]) + const { users, count, isLoading, isError, error } = useAdminUsers( + { + limit: PAGE_SIZE, + offset: pageIndex * PAGE_SIZE, + ...params, + }, + { + keepPreviousData: true, + } + ) const columns = useColumns() const table = useReactTable({ data: users ?? [], columns, + pageCount: Math.ceil((count ?? 0) / PAGE_SIZE), state: { - rowSelection, + pagination, }, - onRowSelectionChange: setRowSelection, + onPaginationChange: setPagination, getCoreRowModel: getCoreRowModel(), + manualPagination: true, }) const { t } = useTranslation() const navigate = useNavigate() + const noRecords = + !isLoading && + !users?.length && + !Object.values(params).filter(Boolean).length + if (isError) { throw error } @@ -40,56 +80,121 @@ export const UserListTable = () => {
{t("users.domain")} +
- - - {table.getHeaderGroups().map((headerGroup) => { - return ( - - {headerGroup.headers.map((header) => { + {!noRecords && ( +
+
+
+ + +
+
+ )} + {noRecords ? ( + + ) : ( +
+ {!isLoading && !users?.length ? ( +
+ +
+ ) : ( +
+ + {table.getHeaderGroups().map((headerGroup) => { return ( - - {flexRender( - header.column.columnDef.header, - header.getContext() - )} - + + {headerGroup.headers.map((header) => { + return ( + + {flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + ) })} - - ) - })} - - - {table.getRowModel().rows.map((row) => ( - navigate(row.original.id)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
+ + + {table.getRowModel().rows.map((row) => ( + navigate(row.original.id)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ))} + + + )} + +
+ )} ) } +const UserActions = ({ user }: { user: Omit }) => { + const { t } = useTranslation() + + return ( + , + label: t("general.edit"), + to: `${user.id}/edit`, + }, + ], + }, + ]} + /> + ) +} + const columnHelper = createColumnHelper>() const useColumns = () => { @@ -97,59 +202,37 @@ const useColumns = () => { return useMemo( () => [ - columnHelper.display({ - id: "select", - header: ({ table }) => { - return ( - - table.toggleAllPageRowsSelected(!!value) - } - /> - ) - }, + columnHelper.accessor("email", { + header: t("fields.email"), cell: ({ row }) => { - return ( - row.toggleSelected(!!value)} - onClick={(e) => { - e.stopPropagation() - }} - /> - ) + return row.original.email }, }), columnHelper.display({ id: "name", header: t("fields.name"), cell: ({ row }) => { - const { first_name, last_name } = row.original + const name = [row.original.first_name, row.original.last_name] + .filter(Boolean) + .join(" ") - if (!first_name && !last_name) { + if (!name) { return - } - return `${first_name || ""} ${last_name || ""}`.trim() - }, - }), - columnHelper.accessor("email", { - header: t("fields.email"), - cell: ({ row }) => { - return row.original.email + return name }, }), columnHelper.accessor("role", { - header: t("users.role"), + header: t("fields.role"), cell: ({ row }) => { return t(`users.roles.${row.original.role}`) }, }), + columnHelper.display({ + id: "actions", + cell: ({ row }) => , + }), ], [t] ) diff --git a/packages/admin-next/dashboard/src/routes/users/user-list/user-list.tsx b/packages/admin-next/dashboard/src/routes/users/user-list/user-list.tsx index 412a8077ceb9c..c90a5215fd05b 100644 --- a/packages/admin-next/dashboard/src/routes/users/user-list/user-list.tsx +++ b/packages/admin-next/dashboard/src/routes/users/user-list/user-list.tsx @@ -1,9 +1,11 @@ +import { Outlet } from "react-router-dom" import { UserListTable } from "./components/user-list-table" export const UserList = () => { return (
+
) } From 8c7a031090cb9ae9bdbc100e6560cc88ed7bf3e2 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Tue, 30 Jan 2024 19:01:54 +0200 Subject: [PATCH 06/11] docs: refactoring of docblock generator tool (#6261) small refactoring of the docblock generator tool that moves all git operations and requests into the `GitManager` --- .../src/classes/git-manager.ts | 46 +++++++++++++------ .../src/commands/run-git-changes.ts | 15 ++---- .../src/commands/run-git-commit.ts | 17 ++----- 3 files changed, 39 insertions(+), 39 deletions(-) diff --git a/docs-util/packages/docblock-generator/src/classes/git-manager.ts b/docs-util/packages/docblock-generator/src/classes/git-manager.ts index 8bb1f6b5a80c4..6d63601a7fafd 100644 --- a/docs-util/packages/docblock-generator/src/classes/git-manager.ts +++ b/docs-util/packages/docblock-generator/src/classes/git-manager.ts @@ -1,4 +1,7 @@ import { Octokit } from "octokit" +import promiseExec from "../utils/promise-exec.js" +import getMonorepoRoot from "../utils/get-monorepo-root.js" +import filterFiles from "../utils/filter-files.js" type Options = { owner?: string @@ -52,19 +55,7 @@ export class GitManager { await Promise.all( commits.map(async (commit) => { - const { - data: { files: commitFiles }, - } = await this.octokit.request( - "GET /repos/{owner}/{repo}/commits/{ref}", - { - owner: this.owner, - repo: this.repo, - ref: commit.sha, - headers: { - "X-GitHub-Api-Version": this.gitApiVersion, - }, - } - ) + const commitFiles = await this.getCommitFiles(commit.sha) commitFiles?.forEach((commitFile) => files.add(commitFile.filename)) }) @@ -72,4 +63,33 @@ export class GitManager { return [...files] } + + async getDiffFiles(): Promise { + const childProcess = await promiseExec( + `git diff --name-only -- "packages/**/**.ts" "packages/**/*.js" "packages/**/*.tsx" "packages/**/*.jsx"`, + { + cwd: getMonorepoRoot(), + } + ) + + return filterFiles( + childProcess.stdout.toString().split("\n").filter(Boolean) + ) + } + + async getCommitFiles(commitSha: string) { + const { + data: { files }, + } = await this.octokit.request("GET /repos/{owner}/{repo}/commits/{ref}", { + owner: "medusajs", + repo: "medusa", + ref: commitSha, + headers: { + "X-GitHub-Api-Version": "2022-11-28", + }, + per_page: 3000, + }) + + return files + } } diff --git a/docs-util/packages/docblock-generator/src/commands/run-git-changes.ts b/docs-util/packages/docblock-generator/src/commands/run-git-changes.ts index 4a8bb31a0e68a..b46164d5405ee 100644 --- a/docs-util/packages/docblock-generator/src/commands/run-git-changes.ts +++ b/docs-util/packages/docblock-generator/src/commands/run-git-changes.ts @@ -1,22 +1,13 @@ import path from "path" import DocblockGenerator from "../classes/docblock-generator.js" import getMonorepoRoot from "../utils/get-monorepo-root.js" -import promiseExec from "../utils/promise-exec.js" -import filterFiles from "../utils/filter-files.js" +import { GitManager } from "../classes/git-manager.js" export default async function runGitChanges() { const monorepoPath = getMonorepoRoot() // retrieve the changed files under `packages` in the monorepo root. - const childProcess = await promiseExec( - `git diff --name-only -- "packages/**/**.ts" "packages/**/*.js" "packages/**/*.tsx" "packages/**/*.jsx"`, - { - cwd: monorepoPath, - } - ) - - let files = filterFiles( - childProcess.stdout.toString().split("\n").filter(Boolean) - ) + const gitManager = new GitManager() + let files = await gitManager.getDiffFiles() if (!files.length) { console.log(`No file changes detected.`) diff --git a/docs-util/packages/docblock-generator/src/commands/run-git-commit.ts b/docs-util/packages/docblock-generator/src/commands/run-git-commit.ts index d93ca04276146..cb32bb21a959c 100644 --- a/docs-util/packages/docblock-generator/src/commands/run-git-commit.ts +++ b/docs-util/packages/docblock-generator/src/commands/run-git-commit.ts @@ -1,26 +1,15 @@ -import { Octokit } from "@octokit/core" import filterFiles from "../utils/filter-files.js" import path from "path" import getMonorepoRoot from "../utils/get-monorepo-root.js" import DocblockGenerator from "../classes/docblock-generator.js" +import { GitManager } from "../classes/git-manager.js" export default async function (commitSha: string) { const monorepoPath = getMonorepoRoot() // retrieve the files changed in the commit - const octokit = new Octokit({ - auth: process.env.GH_TOKEN, - }) + const gitManager = new GitManager() - const { - data: { files }, - } = await octokit.request("GET /repos/{owner}/{repo}/commits/{ref}", { - owner: "medusajs", - repo: "medusa", - ref: commitSha, - headers: { - "X-GitHub-Api-Version": "2022-11-28", - }, - }) + const files = await gitManager.getCommitFiles(commitSha) // filter changed files let filteredFiles = filterFiles(files?.map((file) => file.filename) || []) From ca0e0631afb0dd261d372358f6ffe67f92cf7eed Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Tue, 30 Jan 2024 20:37:53 +0100 Subject: [PATCH 07/11] feat(customer): add customer group management apis (#6233) **What** ``` POST /admin/customer-groups POST /admin/customer-groups/:id GET /admin/customer-groups/:id DELETE /admin/customer-groups/:id ``` - Workflows --- .../admin/create-customer-group.ts | 65 ++++++++++++++++++ .../admin/delete-customer-group.ts | 65 ++++++++++++++++++ .../admin/list-customer-groups.spec.ts | 2 +- .../admin/retrieve-customer-group.ts | 65 ++++++++++++++++++ .../admin/update-customer-group.ts | 68 +++++++++++++++++++ .../core-flows/src/customer-group/index.ts | 2 + .../steps/create-customer-groups.ts | 33 +++++++++ .../steps/delete-customer-groups.ts | 30 ++++++++ .../src/customer-group/steps/index.ts | 3 + .../steps/update-customer-groups.ts | 58 ++++++++++++++++ .../workflows/create-customer-groups.ts | 13 ++++ .../workflows/delete-customer-groups.ts | 12 ++++ .../src/customer-group/workflows/index.ts | 3 + .../workflows/update-customer-groups.ts | 20 ++++++ packages/core-flows/src/index.ts | 1 + .../customer/src/services/customer-module.ts | 22 +++--- .../admin/customer-groups/[id]/route.ts | 63 +++++++++++++++++ .../src/api-v2/admin/customer-groups/route.ts | 26 ++++++- packages/types/src/customer/mutations.ts | 2 +- packages/types/src/customer/service.ts | 7 +- 20 files changed, 540 insertions(+), 20 deletions(-) create mode 100644 integration-tests/plugins/__tests__/customer-group/admin/create-customer-group.ts create mode 100644 integration-tests/plugins/__tests__/customer-group/admin/delete-customer-group.ts rename integration-tests/plugins/__tests__/{customer => customer-group}/admin/list-customer-groups.spec.ts (97%) create mode 100644 integration-tests/plugins/__tests__/customer-group/admin/retrieve-customer-group.ts create mode 100644 integration-tests/plugins/__tests__/customer-group/admin/update-customer-group.ts create mode 100644 packages/core-flows/src/customer-group/index.ts create mode 100644 packages/core-flows/src/customer-group/steps/create-customer-groups.ts create mode 100644 packages/core-flows/src/customer-group/steps/delete-customer-groups.ts create mode 100644 packages/core-flows/src/customer-group/steps/index.ts create mode 100644 packages/core-flows/src/customer-group/steps/update-customer-groups.ts create mode 100644 packages/core-flows/src/customer-group/workflows/create-customer-groups.ts create mode 100644 packages/core-flows/src/customer-group/workflows/delete-customer-groups.ts create mode 100644 packages/core-flows/src/customer-group/workflows/index.ts create mode 100644 packages/core-flows/src/customer-group/workflows/update-customer-groups.ts create mode 100644 packages/medusa/src/api-v2/admin/customer-groups/[id]/route.ts diff --git a/integration-tests/plugins/__tests__/customer-group/admin/create-customer-group.ts b/integration-tests/plugins/__tests__/customer-group/admin/create-customer-group.ts new file mode 100644 index 0000000000000..b987f255c8c0b --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/create-customer-group.ts @@ -0,0 +1,65 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/customer-groups", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should create a customer group", async () => { + const api = useApi() as any + const response = await api.post( + `/admin/customer-groups`, + { + name: "VIP", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.customer_group).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "VIP", + created_by: "admin_user", + }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer-group/admin/delete-customer-group.ts b/integration-tests/plugins/__tests__/customer-group/admin/delete-customer-group.ts new file mode 100644 index 0000000000000..88189e4402fab --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/delete-customer-group.ts @@ -0,0 +1,65 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("DELETE /admin/customer-groups/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should delete a group", async () => { + const group = await customerModuleService.createCustomerGroup({ + name: "VIP", + }) + + const api = useApi() as any + const response = await api.delete( + `/admin/customer-groups/${group.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const deletedCustomer = await customerModuleService.retrieveCustomerGroup( + group.id, + { withDeleted: true } + ) + expect(deletedCustomer.deleted_at).toBeTruthy() + }) +}) diff --git a/integration-tests/plugins/__tests__/customer/admin/list-customer-groups.spec.ts b/integration-tests/plugins/__tests__/customer-group/admin/list-customer-groups.spec.ts similarity index 97% rename from integration-tests/plugins/__tests__/customer/admin/list-customer-groups.spec.ts rename to integration-tests/plugins/__tests__/customer-group/admin/list-customer-groups.spec.ts index 538caa7cbf5e7..732f26da349a2 100644 --- a/integration-tests/plugins/__tests__/customer/admin/list-customer-groups.spec.ts +++ b/integration-tests/plugins/__tests__/customer-group/admin/list-customer-groups.spec.ts @@ -53,7 +53,7 @@ describe("GET /admin/customer-groups", () => { expect(response.status).toEqual(200) expect(response.data.count).toEqual(1) - expect(response.data.groups).toEqual([ + expect(response.data.customer_groups).toEqual([ expect.objectContaining({ id: expect.any(String), name: "Test", diff --git a/integration-tests/plugins/__tests__/customer-group/admin/retrieve-customer-group.ts b/integration-tests/plugins/__tests__/customer-group/admin/retrieve-customer-group.ts new file mode 100644 index 0000000000000..f2ad1a6634bd3 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/retrieve-customer-group.ts @@ -0,0 +1,65 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("GET /admin/customer-groups/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should retrieve customer group", async () => { + const group = await customerModuleService.createCustomerGroup({ + name: "Test", + }) + + const api = useApi() as any + const response = await api.get( + `/admin/customer-groups/${group.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.customer_group).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "Test", + }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer-group/admin/update-customer-group.ts b/integration-tests/plugins/__tests__/customer-group/admin/update-customer-group.ts new file mode 100644 index 0000000000000..25d94f0b0b317 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/update-customer-group.ts @@ -0,0 +1,68 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/customer-groups/:id", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should update a customer group", async () => { + const customer = await customerModuleService.createCustomerGroup({ + name: "VIP", + }) + + const api = useApi() as any + const response = await api.post( + `/admin/customer-groups/${customer.id}`, + { + name: "regular", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.customer_group).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: "regular", + }) + ) + }) +}) diff --git a/packages/core-flows/src/customer-group/index.ts b/packages/core-flows/src/customer-group/index.ts new file mode 100644 index 0000000000000..e84516860c47c --- /dev/null +++ b/packages/core-flows/src/customer-group/index.ts @@ -0,0 +1,2 @@ +export * from "./workflows" +export * from "./steps" diff --git a/packages/core-flows/src/customer-group/steps/create-customer-groups.ts b/packages/core-flows/src/customer-group/steps/create-customer-groups.ts new file mode 100644 index 0000000000000..26a8af573205b --- /dev/null +++ b/packages/core-flows/src/customer-group/steps/create-customer-groups.ts @@ -0,0 +1,33 @@ +import { CreateCustomerGroupDTO, ICustomerModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const createCustomerGroupsStepId = "create-customer-groups" +export const createCustomerGroupsStep = createStep( + createCustomerGroupsStepId, + async (data: CreateCustomerGroupDTO[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const createdCustomerGroups = await service.createCustomerGroup(data) + + return new StepResponse( + createdCustomerGroups, + createdCustomerGroups.map( + (createdCustomerGroups) => createdCustomerGroups.id + ) + ) + }, + async (createdCustomerGroupIds, { container }) => { + if (!createdCustomerGroupIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.delete(createdCustomerGroupIds) + } +) diff --git a/packages/core-flows/src/customer-group/steps/delete-customer-groups.ts b/packages/core-flows/src/customer-group/steps/delete-customer-groups.ts new file mode 100644 index 0000000000000..b2b074842c701 --- /dev/null +++ b/packages/core-flows/src/customer-group/steps/delete-customer-groups.ts @@ -0,0 +1,30 @@ +import { ICustomerModuleService } from "@medusajs/types" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +type DeleteCustomerGroupStepInput = string[] + +export const deleteCustomerGroupStepId = "delete-customer-groups" +export const deleteCustomerGroupStep = createStep( + deleteCustomerGroupStepId, + async (ids: DeleteCustomerGroupStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.softDeleteCustomerGroup(ids) + + return new StepResponse(void 0, ids) + }, + async (prevCustomerGroups, { container }) => { + if (!prevCustomerGroups) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.restoreCustomerGroup(prevCustomerGroups) + } +) diff --git a/packages/core-flows/src/customer-group/steps/index.ts b/packages/core-flows/src/customer-group/steps/index.ts new file mode 100644 index 0000000000000..2d99ae94fa022 --- /dev/null +++ b/packages/core-flows/src/customer-group/steps/index.ts @@ -0,0 +1,3 @@ +export * from "./update-customer-groups" +export * from "./delete-customer-groups" +export * from "./create-customer-groups" diff --git a/packages/core-flows/src/customer-group/steps/update-customer-groups.ts b/packages/core-flows/src/customer-group/steps/update-customer-groups.ts new file mode 100644 index 0000000000000..553f1d5ae6c09 --- /dev/null +++ b/packages/core-flows/src/customer-group/steps/update-customer-groups.ts @@ -0,0 +1,58 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + FilterableCustomerGroupProps, + ICustomerModuleService, + CustomerGroupUpdatableFields, +} from "@medusajs/types" +import { + getSelectsAndRelationsFromObjectArray, + promiseAll, +} from "@medusajs/utils" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" + +type UpdateCustomerGroupStepInput = { + selector: FilterableCustomerGroupProps + update: CustomerGroupUpdatableFields +} + +export const updateCustomerGroupStepId = "update-customer-groups" +export const updateCustomerGroupsStep = createStep( + updateCustomerGroupStepId, + async (data: UpdateCustomerGroupStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + const prevCustomerGroups = await service.listCustomerGroups(data.selector, { + select: selects, + relations, + }) + + const customers = await service.updateCustomerGroup( + data.selector, + data.update + ) + + return new StepResponse(customers, prevCustomerGroups) + }, + async (prevCustomerGroups, { container }) => { + if (!prevCustomerGroups) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await promiseAll( + prevCustomerGroups.map((c) => + service.updateCustomerGroup(c.id, { + name: c.name, + }) + ) + ) + } +) diff --git a/packages/core-flows/src/customer-group/workflows/create-customer-groups.ts b/packages/core-flows/src/customer-group/workflows/create-customer-groups.ts new file mode 100644 index 0000000000000..7ee861087eaa4 --- /dev/null +++ b/packages/core-flows/src/customer-group/workflows/create-customer-groups.ts @@ -0,0 +1,13 @@ +import { CustomerGroupDTO, CreateCustomerGroupDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createCustomerGroupsStep } from "../steps" + +type WorkflowInput = { customersData: CreateCustomerGroupDTO[] } + +export const createCustomerGroupsWorkflowId = "create-customer-groups" +export const createCustomerGroupsWorkflow = createWorkflow( + createCustomerGroupsWorkflowId, + (input: WorkflowData): WorkflowData => { + return createCustomerGroupsStep(input.customersData) + } +) diff --git a/packages/core-flows/src/customer-group/workflows/delete-customer-groups.ts b/packages/core-flows/src/customer-group/workflows/delete-customer-groups.ts new file mode 100644 index 0000000000000..b2923eb7c48ab --- /dev/null +++ b/packages/core-flows/src/customer-group/workflows/delete-customer-groups.ts @@ -0,0 +1,12 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteCustomerGroupStep } from "../steps" + +type WorkflowInput = { ids: string[] } + +export const deleteCustomerGroupsWorkflowId = "delete-customer-groups" +export const deleteCustomerGroupsWorkflow = createWorkflow( + deleteCustomerGroupsWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteCustomerGroupStep(input.ids) + } +) diff --git a/packages/core-flows/src/customer-group/workflows/index.ts b/packages/core-flows/src/customer-group/workflows/index.ts new file mode 100644 index 0000000000000..2d99ae94fa022 --- /dev/null +++ b/packages/core-flows/src/customer-group/workflows/index.ts @@ -0,0 +1,3 @@ +export * from "./update-customer-groups" +export * from "./delete-customer-groups" +export * from "./create-customer-groups" diff --git a/packages/core-flows/src/customer-group/workflows/update-customer-groups.ts b/packages/core-flows/src/customer-group/workflows/update-customer-groups.ts new file mode 100644 index 0000000000000..f807f644d2968 --- /dev/null +++ b/packages/core-flows/src/customer-group/workflows/update-customer-groups.ts @@ -0,0 +1,20 @@ +import { + CustomerGroupDTO, + FilterableCustomerGroupProps, + CustomerGroupUpdatableFields, +} from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updateCustomerGroupsStep } from "../steps" + +type WorkflowInput = { + selector: FilterableCustomerGroupProps + update: CustomerGroupUpdatableFields +} + +export const updateCustomerGroupsWorkflowId = "update-customer-groups" +export const updateCustomerGroupsWorkflow = createWorkflow( + updateCustomerGroupsWorkflowId, + (input: WorkflowData): WorkflowData => { + return updateCustomerGroupsStep(input) + } +) diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index 0d26e847b65b8..c31f9fabe4abb 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -3,3 +3,4 @@ export * from "./definitions" export * as Handlers from "./handlers" export * from "./promotion" export * from "./customer" +export * from "./customer-group" diff --git a/packages/customer/src/services/customer-module.ts b/packages/customer/src/services/customer-module.ts index a2352f1d67565..ee50bf7ee8613 100644 --- a/packages/customer/src/services/customer-module.ts +++ b/packages/customer/src/services/customer-module.ts @@ -19,11 +19,7 @@ import { isString, isObject, } from "@medusajs/utils" -import { - entityNameToLinkableKeysMap, - LinkableKeys, - joinerConfig, -} from "../joiner-config" +import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config" import * as services from "../services" type InjectedDependencies = { @@ -111,24 +107,24 @@ export default class CustomerModuleService implements ICustomerModuleService { update( customerId: string, - data: CustomerUpdatableFields, + data: CustomerTypes.CustomerUpdatableFields, sharedContext?: Context ): Promise update( customerIds: string[], - data: CustomerUpdatableFields, + data: CustomerTypes.CustomerUpdatableFields, sharedContext?: Context ): Promise update( selector: CustomerTypes.FilterableCustomerProps, - data: CustomerUpdatableFields, + data: CustomerTypes.CustomerUpdatableFields, sharedContext?: Context ): Promise @InjectTransactionManager("baseRepository_") async update( idsOrSelector: string | string[] | CustomerTypes.FilterableCustomerProps, - data: CustomerUpdatableFields, + data: CustomerTypes.CustomerUpdatableFields, @MedusaContext() sharedContext: Context = {} ) { let updateData: CustomerTypes.UpdateCustomerDTO[] = [] @@ -291,17 +287,17 @@ export default class CustomerModuleService implements ICustomerModuleService { async updateCustomerGroup( groupId: string, - data: Partial, + data: CustomerTypes.CustomerGroupUpdatableFields, sharedContext?: Context ): Promise async updateCustomerGroup( groupIds: string[], - data: Partial, + data: CustomerTypes.CustomerGroupUpdatableFields, sharedContext?: Context ): Promise async updateCustomerGroup( selector: CustomerTypes.FilterableCustomerGroupProps, - data: Partial, + data: CustomerTypes.CustomerGroupUpdatableFields, sharedContext?: Context ): Promise @@ -311,7 +307,7 @@ export default class CustomerModuleService implements ICustomerModuleService { | string | string[] | CustomerTypes.FilterableCustomerGroupProps, - data: Partial, + data: CustomerTypes.CustomerGroupUpdatableFields, @MedusaContext() sharedContext: Context = {} ) { let updateData: CustomerTypes.UpdateCustomerGroupDTO[] = [] diff --git a/packages/medusa/src/api-v2/admin/customer-groups/[id]/route.ts b/packages/medusa/src/api-v2/admin/customer-groups/[id]/route.ts new file mode 100644 index 0000000000000..15fbe1cb341a7 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customer-groups/[id]/route.ts @@ -0,0 +1,63 @@ +import { + updateCustomerGroupsWorkflow, + deleteCustomerGroupsWorkflow, +} from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CustomerGroupUpdatableFields, + ICustomerModuleService, +} from "@medusajs/types" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const customerModuleService = req.scope.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const group = await customerModuleService.retrieveCustomerGroup( + req.params.id, + { + select: req.retrieveConfig.select, + relations: req.retrieveConfig.relations, + } + ) + + res.status(200).json({ customer_group: group }) +} + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const updateGroups = updateCustomerGroupsWorkflow(req.scope) + const { result, errors } = await updateGroups.run({ + input: { + selector: { id: req.params.id }, + update: req.validatedBody as CustomerGroupUpdatableFields, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ customer_group: result[0] }) +} + +export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => { + const id = req.params.id + const deleteCustomerGroups = deleteCustomerGroupsWorkflow(req.scope) + + const { errors } = await deleteCustomerGroups.run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + id, + object: "customer_group", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/customer-groups/route.ts b/packages/medusa/src/api-v2/admin/customer-groups/route.ts index 03cbdd4728e6c..eea7d452f6d18 100644 --- a/packages/medusa/src/api-v2/admin/customer-groups/route.ts +++ b/packages/medusa/src/api-v2/admin/customer-groups/route.ts @@ -1,5 +1,6 @@ +import { createCustomerGroupsWorkflow } from "@medusajs/core-flows" import { ModuleRegistrationName } from "@medusajs/modules-sdk" -import { ICustomerModuleService } from "@medusajs/types" +import { CreateCustomerGroupDTO, ICustomerModuleService } from "@medusajs/types" import { MedusaRequest, MedusaResponse } from "../../../types/routing" export const GET = async (req: MedusaRequest, res: MedusaResponse) => { @@ -17,8 +18,29 @@ export const GET = async (req: MedusaRequest, res: MedusaResponse) => { res.json({ count, - groups, + customer_groups: groups, offset, limit, }) } + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const createGroups = createCustomerGroupsWorkflow(req.scope) + const customersData = [ + { + ...(req.validatedBody as CreateCustomerGroupDTO), + created_by: req.user!.id, + }, + ] + + const { result, errors } = await createGroups.run({ + input: { customersData }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ customer_group: result[0] }) +} diff --git a/packages/types/src/customer/mutations.ts b/packages/types/src/customer/mutations.ts index 1e1d42334e300..d84874119f106 100644 --- a/packages/types/src/customer/mutations.ts +++ b/packages/types/src/customer/mutations.ts @@ -71,7 +71,7 @@ export interface CreateCustomerGroupDTO { created_by?: string } -export interface CustomerGroupUpdatableFileds { +export interface CustomerGroupUpdatableFields { name?: string metadata?: Record | null } diff --git a/packages/types/src/customer/service.ts b/packages/types/src/customer/service.ts index 56383da8a42f8..f6f3979744892 100644 --- a/packages/types/src/customer/service.ts +++ b/packages/types/src/customer/service.ts @@ -17,6 +17,7 @@ import { CreateCustomerAddressDTO, CreateCustomerDTO, CreateCustomerGroupDTO, + CustomerGroupUpdatableFields, CustomerUpdatableFields, UpdateCustomerAddressDTO, } from "./mutations" @@ -75,17 +76,17 @@ export interface ICustomerModuleService extends IModuleService { updateCustomerGroup( groupId: string, - data: Partial, + data: CustomerGroupUpdatableFields, sharedContext?: Context ): Promise updateCustomerGroup( groupIds: string[], - data: Partial, + data: CustomerGroupUpdatableFields, sharedContext?: Context ): Promise updateCustomerGroup( selector: FilterableCustomerGroupProps, - data: Partial, + data: CustomerGroupUpdatableFields, sharedContext?: Context ): Promise From 36ec3ea3aaad3d4cba30b5c3e39643c061aabe8f Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Wed, 31 Jan 2024 10:55:07 +0100 Subject: [PATCH 08/11] feat(customer): admin addresses (#6235) **What** - GET /admin/customers/:id/addresses - POST /admin/customers/:id/addresses - POST /admin/customers/:id/addresses/:address_id - DELETE /admin/customers/:id/addresses/:address_id --- .../admin/create-customer-addresses.ts | 81 +++++++ .../admin/delete-customer-address.spec.ts | 73 ++++++ .../customer/admin/list-customer-addresses.ts | 111 +++++++++ .../admin/update-customer-address.spec.ts | 77 +++++++ .../src/customer/steps/create-addresses.ts | 34 +++ .../src/customer/steps/delete-addresses.ts | 32 +++ .../core-flows/src/customer/steps/index.ts | 3 + .../src/customer/steps/update-addresses.ts | 54 +++++ .../customer/workflows/create-addresses.ts | 13 ++ .../customer/workflows/delete-addresses.ts | 12 + .../src/customer/workflows/index.ts | 3 + .../customer/workflows/update-addresses.ts | 19 ++ .../services/customer-module/index.spec.ts | 32 ++- .../customer/src/services/customer-module.ts | 81 ++++++- .../[id]/addresses/[address_id]/route.ts | 60 +++++ .../admin/customers/[id]/addresses/route.ts | 51 +++++ .../src/api-v2/admin/customers/middlewares.ts | 23 ++ .../api-v2/admin/customers/query-config.ts | 39 +++- .../src/api-v2/admin/customers/validators.ts | 213 +++++++++++++++++- packages/types/src/customer/service.ts | 13 ++ 20 files changed, 990 insertions(+), 34 deletions(-) create mode 100644 integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts create mode 100644 integration-tests/plugins/__tests__/customer/admin/delete-customer-address.spec.ts create mode 100644 integration-tests/plugins/__tests__/customer/admin/list-customer-addresses.ts create mode 100644 integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts create mode 100644 packages/core-flows/src/customer/steps/create-addresses.ts create mode 100644 packages/core-flows/src/customer/steps/delete-addresses.ts create mode 100644 packages/core-flows/src/customer/steps/update-addresses.ts create mode 100644 packages/core-flows/src/customer/workflows/create-addresses.ts create mode 100644 packages/core-flows/src/customer/workflows/delete-addresses.ts create mode 100644 packages/core-flows/src/customer/workflows/update-addresses.ts create mode 100644 packages/medusa/src/api-v2/admin/customers/[id]/addresses/[address_id]/route.ts create mode 100644 packages/medusa/src/api-v2/admin/customers/[id]/addresses/route.ts diff --git a/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts b/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts new file mode 100644 index 0000000000000..c860f237c53cb --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts @@ -0,0 +1,81 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/customers/:id/addresses", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should create a customer address", async () => { + // Create a customer + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + }) + + const api = useApi() as any + const response = await api.post( + `/admin/customers/${customer.id}/addresses`, + { + first_name: "John", + last_name: "Doe", + address_1: "Test street 1", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.address).toEqual( + expect.objectContaining({ + id: expect.any(String), + first_name: "John", + last_name: "Doe", + address_1: "Test street 1", + }) + ) + + const customerWithAddresses = await customerModuleService.retrieve( + customer.id, + { relations: ["addresses"] } + ) + + expect(customerWithAddresses.addresses?.length).toEqual(1) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer/admin/delete-customer-address.spec.ts b/integration-tests/plugins/__tests__/customer/admin/delete-customer-address.spec.ts new file mode 100644 index 0000000000000..a604b2b021dc0 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/delete-customer-address.spec.ts @@ -0,0 +1,73 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("DELETE /admin/customers/:id/addresses/:address_id", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should update a customer address", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + }) + + const address = await customerModuleService.addAddresses({ + customer_id: customer.id, + first_name: "John", + last_name: "Doe", + address_1: "Test street 1", + }) + + const api = useApi() as any + const response = await api.delete( + `/admin/customers/${customer.id}/addresses/${address.id}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const updatedCustomer = await customerModuleService.retrieve(customer.id, { + relations: ["addresses"], + }) + + expect(updatedCustomer.addresses?.length).toEqual(0) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer/admin/list-customer-addresses.ts b/integration-tests/plugins/__tests__/customer/admin/list-customer-addresses.ts new file mode 100644 index 0000000000000..b87ac4e59250e --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/list-customer-addresses.ts @@ -0,0 +1,111 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("GET /admin/customers/:id/addresses", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should get all customer addresses and its count", async () => { + const [customer] = await customerModuleService.create([ + { + first_name: "Test", + last_name: "Test", + email: "test@me.com", + addresses: [ + { + first_name: "Test", + last_name: "Test", + address_1: "Test street 1", + }, + { + first_name: "Test", + last_name: "Test", + address_1: "Test street 2", + }, + { + first_name: "Test", + last_name: "Test", + address_1: "Test street 3", + }, + ], + }, + { + first_name: "Test Test", + last_name: "Test Test", + addresses: [ + { + first_name: "Test TEST", + last_name: "Test TEST", + address_1: "NOT street 1", + }, + ], + }, + ]) + + const api = useApi() as any + const response = await api.get( + `/admin/customers/${customer.id}/addresses`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(3) + expect(response.data.addresses).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + customer_id: customer.id, + address_1: "Test street 1", + }), + expect.objectContaining({ + id: expect.any(String), + customer_id: customer.id, + address_1: "Test street 2", + }), + expect.objectContaining({ + id: expect.any(String), + customer_id: customer.id, + address_1: "Test street 3", + }), + ]) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts b/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts new file mode 100644 index 0000000000000..4ce6fac25d715 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts @@ -0,0 +1,77 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/customers/:id/addresses/:address_id", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should update a customer address", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + }) + + const address = await customerModuleService.addAddresses({ + customer_id: customer.id, + first_name: "John", + last_name: "Doe", + address_1: "Test street 1", + }) + + const api = useApi() as any + const response = await api.post( + `/admin/customers/${customer.id}/addresses/${address.id}`, + { + first_name: "Jane", + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.address).toEqual( + expect.objectContaining({ + id: expect.any(String), + first_name: "Jane", + last_name: "Doe", + }) + ) + }) +}) diff --git a/packages/core-flows/src/customer/steps/create-addresses.ts b/packages/core-flows/src/customer/steps/create-addresses.ts new file mode 100644 index 0000000000000..139aebf0d248a --- /dev/null +++ b/packages/core-flows/src/customer/steps/create-addresses.ts @@ -0,0 +1,34 @@ +import { + ICustomerModuleService, + CreateCustomerAddressDTO, +} from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const createCustomerAddressesStepId = "create-customer-addresses" +export const createCustomerAddressesStep = createStep( + createCustomerAddressesStepId, + async (data: CreateCustomerAddressDTO[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const addresses = await service.addAddresses(data) + + return new StepResponse( + addresses, + addresses.map((address) => address.id) + ) + }, + async (ids, { container }) => { + if (!ids?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.deleteAddress(ids) + } +) diff --git a/packages/core-flows/src/customer/steps/delete-addresses.ts b/packages/core-flows/src/customer/steps/delete-addresses.ts new file mode 100644 index 0000000000000..c6ed1732993ac --- /dev/null +++ b/packages/core-flows/src/customer/steps/delete-addresses.ts @@ -0,0 +1,32 @@ +import { ICustomerModuleService } from "@medusajs/types" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +type DeleteCustomerAddressStepInput = string[] +export const deleteCustomerAddressesStepId = "delete-customer-addresses" +export const deleteCustomerAddressesStep = createStep( + deleteCustomerAddressesStepId, + async (ids: DeleteCustomerAddressStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const existing = await service.listAddresses({ + id: ids, + }) + await service.deleteAddress(ids) + + return new StepResponse(void 0, existing) + }, + async (prevAddresses, { container }) => { + if (!prevAddresses?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.addAddresses(prevAddresses) + } +) diff --git a/packages/core-flows/src/customer/steps/index.ts b/packages/core-flows/src/customer/steps/index.ts index 6ea82b9cd6882..cf2fe8e9b0cac 100644 --- a/packages/core-flows/src/customer/steps/index.ts +++ b/packages/core-flows/src/customer/steps/index.ts @@ -1,3 +1,6 @@ export * from "./create-customers" export * from "./update-customers" export * from "./delete-customers" +export * from "./create-addresses" +export * from "./update-addresses" +export * from "./delete-addresses" diff --git a/packages/core-flows/src/customer/steps/update-addresses.ts b/packages/core-flows/src/customer/steps/update-addresses.ts new file mode 100644 index 0000000000000..17d7d68be1901 --- /dev/null +++ b/packages/core-flows/src/customer/steps/update-addresses.ts @@ -0,0 +1,54 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CustomerAddressDTO, + FilterableCustomerAddressProps, + ICustomerModuleService, +} from "@medusajs/types" +import { + getSelectsAndRelationsFromObjectArray, + promiseAll, +} from "@medusajs/utils" +import { createStep, StepResponse } from "@medusajs/workflows-sdk" + +type UpdateCustomerAddresseStepInput = { + selector: FilterableCustomerAddressProps + update: Partial +} + +export const updateCustomerAddresseStepId = "update-customer-addresses" +export const updateCustomerAddressesStep = createStep( + updateCustomerAddresseStepId, + async (data: UpdateCustomerAddresseStepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray([ + data.update, + ]) + const prevCustomers = await service.listAddresses(data.selector, { + select: selects, + relations, + }) + + const customerAddresses = await service.updateAddress( + data.selector, + data.update + ) + + return new StepResponse(customerAddresses, prevCustomers) + }, + async (prevCustomerAddresses, { container }) => { + if (!prevCustomerAddresses) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await promiseAll( + prevCustomerAddresses.map((c) => service.updateAddress(c.id, { ...c })) + ) + } +) diff --git a/packages/core-flows/src/customer/workflows/create-addresses.ts b/packages/core-flows/src/customer/workflows/create-addresses.ts new file mode 100644 index 0000000000000..12bc2d2ff9db7 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/create-addresses.ts @@ -0,0 +1,13 @@ +import { CreateCustomerAddressDTO, CustomerAddressDTO } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createCustomerAddressesStep } from "../steps" + +type WorkflowInput = { addresses: CreateCustomerAddressDTO[] } + +export const createCustomerAddressesWorkflowId = "create-customer-addresses" +export const createCustomerAddressesWorkflow = createWorkflow( + createCustomerAddressesWorkflowId, + (input: WorkflowData): WorkflowData => { + return createCustomerAddressesStep(input.addresses) + } +) diff --git a/packages/core-flows/src/customer/workflows/delete-addresses.ts b/packages/core-flows/src/customer/workflows/delete-addresses.ts new file mode 100644 index 0000000000000..3a0e2727b4121 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/delete-addresses.ts @@ -0,0 +1,12 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteCustomerAddressesStep } from "../steps" + +type WorkflowInput = { ids: string[] } + +export const deleteCustomerAddressesWorkflowId = "delete-customer-addresses" +export const deleteCustomerAddressesWorkflow = createWorkflow( + deleteCustomerAddressesWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteCustomerAddressesStep(input.ids) + } +) diff --git a/packages/core-flows/src/customer/workflows/index.ts b/packages/core-flows/src/customer/workflows/index.ts index 6ea82b9cd6882..cf2fe8e9b0cac 100644 --- a/packages/core-flows/src/customer/workflows/index.ts +++ b/packages/core-flows/src/customer/workflows/index.ts @@ -1,3 +1,6 @@ export * from "./create-customers" export * from "./update-customers" export * from "./delete-customers" +export * from "./create-addresses" +export * from "./update-addresses" +export * from "./delete-addresses" diff --git a/packages/core-flows/src/customer/workflows/update-addresses.ts b/packages/core-flows/src/customer/workflows/update-addresses.ts new file mode 100644 index 0000000000000..fa8aff557c292 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/update-addresses.ts @@ -0,0 +1,19 @@ +import { + FilterableCustomerAddressProps, + CustomerAddressDTO, +} from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { updateCustomerAddressesStep } from "../steps" + +type WorkflowInput = { + selector: FilterableCustomerAddressProps + update: Partial +} + +export const updateCustomerAddressesWorkflowId = "update-customer-addresses" +export const updateCustomerAddressesWorkflow = createWorkflow( + updateCustomerAddressesWorkflowId, + (input: WorkflowData): WorkflowData => { + return updateCustomerAddressesStep(input) + } +) diff --git a/packages/customer/integration-tests/__tests__/services/customer-module/index.spec.ts b/packages/customer/integration-tests/__tests__/services/customer-module/index.spec.ts index 09769a8e599a4..7cde20eed819d 100644 --- a/packages/customer/integration-tests/__tests__/services/customer-module/index.spec.ts +++ b/packages/customer/integration-tests/__tests__/services/customer-module/index.spec.ts @@ -80,26 +80,22 @@ describe("Customer Module Service", () => { } const customer = await service.create(customerData) - expect(customer).toEqual( + const [address] = await service.listAddresses({ + customer_id: customer.id, + }) + + expect(address).toEqual( expect.objectContaining({ id: expect.any(String), - company_name: "Acme Corp", - first_name: "John", - last_name: "Doe", - addresses: expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - address_1: "Testvej 1", - address_2: "Testvej 2", - city: "Testby", - country_code: "DK", - province: "Test", - postal_code: "8000", - phone: "123456789", - metadata: expect.objectContaining({ membership: "gold" }), - is_default_shipping: true, - }), - ]), + address_1: "Testvej 1", + address_2: "Testvej 2", + city: "Testby", + country_code: "DK", + province: "Test", + postal_code: "8000", + phone: "123456789", + metadata: expect.objectContaining({ membership: "gold" }), + is_default_shipping: true, }) ) }) diff --git a/packages/customer/src/services/customer-module.ts b/packages/customer/src/services/customer-module.ts index ee50bf7ee8613..542b618c57cc1 100644 --- a/packages/customer/src/services/customer-module.ts +++ b/packages/customer/src/services/customer-module.ts @@ -96,10 +96,32 @@ export default class CustomerModuleService implements ICustomerModuleService { @MedusaContext() sharedContext: Context = {} ) { const data = Array.isArray(dataOrArray) ? dataOrArray : [dataOrArray] - const customer = await this.customerService_.create(data, sharedContext) + + // keep address data for creation + const addressData = data.map((d) => d.addresses) + + const customers = await this.customerService_.create(data, sharedContext) + + // decorate addresses with customer ids + // filter out addresses without data + const addressDataWithCustomerIds = addressData + .map((addresses, i) => { + if (!addresses) { + return [] + } + + return addresses.map((address) => ({ + ...address, + customer_id: customers[i].id, + })) + }) + .flat() + + await this.addressService_.create(addressDataWithCustomerIds, sharedContext) + const serialized = await this.baseRepository_.serialize< CustomerTypes.CustomerDTO[] - >(customer, { + >(customers, { populate: true, }) return Array.isArray(dataOrArray) ? serialized : serialized[0] @@ -511,6 +533,40 @@ export default class CustomerModuleService implements ICustomerModuleService { return serialized } + async deleteAddress(addressId: string, sharedContext?: Context): Promise + async deleteAddress( + addressIds: string[], + sharedContext?: Context + ): Promise + async deleteAddress( + selector: CustomerTypes.FilterableCustomerAddressProps, + sharedContext?: Context + ): Promise + + @InjectTransactionManager("baseRepository_") + async deleteAddress( + addressIdOrSelector: + | string + | string[] + | CustomerTypes.FilterableCustomerAddressProps, + @MedusaContext() sharedContext: Context = {} + ) { + let toDelete = Array.isArray(addressIdOrSelector) + ? addressIdOrSelector + : [addressIdOrSelector as string] + + if (isObject(addressIdOrSelector)) { + const ids = await this.addressService_.list( + addressIdOrSelector, + { select: ["id"] }, + sharedContext + ) + toDelete = ids.map(({ id }) => id) + } + + await this.addressService_.delete(toDelete, sharedContext) + } + @InjectManager("baseRepository_") async listAddresses( filters?: CustomerTypes.FilterableCustomerAddressProps, @@ -528,6 +584,27 @@ export default class CustomerModuleService implements ICustomerModuleService { >(addresses, { populate: true }) } + @InjectManager("baseRepository_") + async listAndCountAddresses( + filters?: CustomerTypes.FilterableCustomerAddressProps, + config?: FindConfig, + @MedusaContext() sharedContext: Context = {} + ): Promise<[CustomerTypes.CustomerAddressDTO[], number]> { + const [addresses, count] = await this.addressService_.listAndCount( + filters, + config, + sharedContext + ) + + return [ + await this.baseRepository_.serialize( + addresses, + { populate: true } + ), + count, + ] + } + async removeCustomerFromGroup( groupCustomerPair: CustomerTypes.GroupCustomerPair, sharedContext?: Context diff --git a/packages/medusa/src/api-v2/admin/customers/[id]/addresses/[address_id]/route.ts b/packages/medusa/src/api-v2/admin/customers/[id]/addresses/[address_id]/route.ts new file mode 100644 index 0000000000000..6eecdf55c65e1 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customers/[id]/addresses/[address_id]/route.ts @@ -0,0 +1,60 @@ +import { + updateCustomerAddressesWorkflow, + deleteCustomerAddressesWorkflow, +} from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CustomerAddressDTO, ICustomerModuleService } from "@medusajs/types" +import { MedusaRequest, MedusaResponse } from "../../../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const customerModuleService = req.scope.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const [address] = await customerModuleService.listAddresses( + { id: req.params.address_id, customer_id: req.params.id }, + { + select: req.retrieveConfig.select, + relations: req.retrieveConfig.relations, + } + ) + + res.status(200).json({ address }) +} + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const updateAddresses = updateCustomerAddressesWorkflow(req.scope) + const { result, errors } = await updateAddresses.run({ + input: { + selector: { id: req.params.address_id, customer_id: req.params.id }, + update: req.validatedBody as Partial, + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ address: result[0] }) +} + +export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => { + const id = req.params.address_id + const deleteAddress = deleteCustomerAddressesWorkflow(req.scope) + + const { errors } = await deleteAddress.run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + id, + object: "address", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/customers/[id]/addresses/route.ts b/packages/medusa/src/api-v2/admin/customers/[id]/addresses/route.ts new file mode 100644 index 0000000000000..91927dee0aedc --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customers/[id]/addresses/route.ts @@ -0,0 +1,51 @@ +import { createCustomerAddressesWorkflow } from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { + CreateCustomerAddressDTO, + ICustomerModuleService, +} from "@medusajs/types" +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const customerId = req.params.id + + const customerModuleService = req.scope.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const [addresses, count] = await customerModuleService.listAndCountAddresses( + { ...req.filterableFields, customer_id: customerId }, + req.listConfig + ) + + const { offset, limit } = req.validatedQuery + + res.json({ + count, + addresses, + offset, + limit, + }) +} + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const customerId = req.params.id + const createAddresses = createCustomerAddressesWorkflow(req.scope) + const addresses = [ + { + ...(req.validatedBody as CreateCustomerAddressDTO), + customer_id: customerId, + }, + ] + + const { result, errors } = await createAddresses.run({ + input: { addresses }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ address: result[0] }) +} diff --git a/packages/medusa/src/api-v2/admin/customers/middlewares.ts b/packages/medusa/src/api-v2/admin/customers/middlewares.ts index 3d72dcc9873ef..3e687eec159d5 100644 --- a/packages/medusa/src/api-v2/admin/customers/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/customers/middlewares.ts @@ -6,6 +6,9 @@ import { AdminGetCustomersCustomerParams, AdminPostCustomersReq, AdminPostCustomersCustomerReq, + AdminPostCustomersCustomerAddressesReq, + AdminGetCustomersCustomerAddressesParams, + AdminPostCustomersCustomerAddressesAddressReq, } from "./validators" export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [ @@ -39,4 +42,24 @@ export const adminCustomerRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/customers/:id", middlewares: [transformBody(AdminPostCustomersCustomerReq)], }, + { + method: ["POST"], + matcher: "/admin/customers/:id/addresses", + middlewares: [transformBody(AdminPostCustomersCustomerAddressesReq)], + }, + { + method: ["POST"], + matcher: "/admin/customers/:id/addresses/:address_id", + middlewares: [transformBody(AdminPostCustomersCustomerAddressesAddressReq)], + }, + { + method: ["GET"], + matcher: "/admin/customers/:id/addresses", + middlewares: [ + transformQuery( + AdminGetCustomersCustomerAddressesParams, + QueryConfig.listAddressesTransformQueryConfig + ), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/customers/query-config.ts b/packages/medusa/src/api-v2/admin/customers/query-config.ts index 3702bea7656db..072f73a1e67c7 100644 --- a/packages/medusa/src/api-v2/admin/customers/query-config.ts +++ b/packages/medusa/src/api-v2/admin/customers/query-config.ts @@ -1,10 +1,5 @@ export const defaultAdminCustomerRelations = [] -export const allowedAdminCustomerRelations = [ - "groups", - "default_shipping_address", - "default_billing_address", - "addresses", -] +export const allowedAdminCustomerRelations = ["groups", "addresses"] export const defaultAdminCustomerFields = [ "id", "company_name", @@ -27,3 +22,35 @@ export const listTransformQueryConfig = { ...retrieveTransformQueryConfig, isList: true, } + +export const defaultAdminCustomerAddressRelations = [] +export const allowedAdminCustomerAddressRelations = ["customer"] +export const defaultAdminCustomerAddressFields = [ + "id", + "company", + "customer_id", + "first_name", + "last_name", + "address_1", + "address_2", + "city", + "province", + "postal_code", + "country_code", + "phone", + "metadata", + "created_at", + "updated_at", +] + +export const retrieveAddressTransformQueryConfig = { + defaultFields: defaultAdminCustomerAddressFields, + defaultRelations: defaultAdminCustomerAddressRelations, + allowedRelations: allowedAdminCustomerAddressRelations, + isList: false, +} + +export const listAddressesTransformQueryConfig = { + ...retrieveAddressTransformQueryConfig, + isList: true, +} diff --git a/packages/medusa/src/api-v2/admin/customers/validators.ts b/packages/medusa/src/api-v2/admin/customers/validators.ts index 40703d4961ff7..20a0264d32fab 100644 --- a/packages/medusa/src/api-v2/admin/customers/validators.ts +++ b/packages/medusa/src/api-v2/admin/customers/validators.ts @@ -1,6 +1,7 @@ import { OperatorMap } from "@medusajs/types" import { Transform, Type } from "class-transformer" import { + IsBoolean, IsNotEmpty, IsOptional, IsString, @@ -29,14 +30,6 @@ export class AdminGetCustomersParams extends extendedFindParamsMixin({ @Type(() => FilterableCustomerGroupPropsValidator) groups?: FilterableCustomerGroupPropsValidator | string | string[] - @IsOptional() - @IsString({ each: true }) - default_billing_address_id?: string | string[] | null - - @IsOptional() - @IsString({ each: true }) - default_shipping_address_id?: string | string[] | null - @IsOptional() @IsString({ each: true }) company_name?: string | string[] | OperatorMap | null @@ -154,3 +147,207 @@ export class AdminPostCustomersCustomerReq { @IsOptional() phone?: string } + +export class AdminPostCustomersCustomerAddressesReq { + @IsNotEmpty() + @IsString() + @IsOptional() + address_name?: string + + @IsBoolean() + @IsOptional() + is_default_shipping?: boolean + + @IsBoolean() + @IsOptional() + is_default_billing?: boolean + + @IsNotEmpty() + @IsString() + @IsOptional() + company?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + first_name?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + last_name?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + address_1?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + address_2?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + city?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + country_code?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + province?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + postal_code?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + phone?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + metadata?: Record +} + +export class AdminPostCustomersCustomerAddressesAddressReq { + @IsNotEmpty() + @IsString() + @IsOptional() + address_name?: string + + @IsBoolean() + @IsOptional() + is_default_shipping?: boolean + + @IsBoolean() + @IsOptional() + is_default_billing?: boolean + + @IsNotEmpty() + @IsString() + @IsOptional() + company?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + first_name?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + last_name?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + address_1?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + address_2?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + city?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + country_code?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + province?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + postal_code?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + phone?: string + + @IsNotEmpty() + @IsString() + @IsOptional() + metadata?: Record +} + +export class AdminGetCustomersCustomerAddressesParams extends extendedFindParamsMixin( + { + limit: 100, + offset: 0, + } +) { + @IsOptional() + @IsString({ each: true }) + address_name?: string | string[] | OperatorMap + + @IsOptional() + @IsBoolean() + is_default_shipping?: boolean + + @IsOptional() + @IsBoolean() + is_default_billing?: boolean + + @IsOptional() + @IsString({ each: true }) + company?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + first_name?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + last_name?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + address_1?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + address_2?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + city?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + country_code?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + province?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + postal_code?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + phone?: string | string[] | OperatorMap | null + + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + metadata?: OperatorMap> +} diff --git a/packages/types/src/customer/service.ts b/packages/types/src/customer/service.ts index f6f3979744892..8143bd213a22e 100644 --- a/packages/types/src/customer/service.ts +++ b/packages/types/src/customer/service.ts @@ -144,12 +144,25 @@ export interface ICustomerModuleService extends IModuleService { sharedContext?: Context ): Promise + deleteAddress(addressId: string, sharedContext?: Context): Promise + deleteAddress(addressIds: string[], sharedContext?: Context): Promise + deleteAddress( + selector: FilterableCustomerAddressProps, + sharedContext?: Context + ): Promise + listAddresses( filters?: FilterableCustomerAddressProps, config?: FindConfig, sharedContext?: Context ): Promise + listAndCountAddresses( + filters?: FilterableCustomerAddressProps, + config?: FindConfig, + sharedContext?: Context + ): Promise<[CustomerAddressDTO[], number]> + listCustomerGroupRelations( filters?: FilterableCustomerGroupCustomerProps, config?: FindConfig, From f41877ef6150d51cf88cf86b411134def756b54c Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Wed, 31 Jan 2024 11:24:22 +0100 Subject: [PATCH 09/11] feat(customer): add customer group customer management (#6276) **What** - GET /admin/customer-groups/:id/customers - POST /admin/customer-groups/:id/customers/batch - POST /admin/customer-groups/:id/customers/remove Workflows --- .../admin/batch-add-customers.ts | 85 ++++++++++++++++++ .../admin/batch-remove-customers.ts | 89 +++++++++++++++++++ .../admin/list-customer-group-customers.ts | 88 ++++++++++++++++++ .../steps/create-customer-group-customers.ts | 29 ++++++ .../steps/delete-customer-group-customers.ts | 28 ++++++ .../src/customer-group/steps/index.ts | 2 + .../create-customer-group-customers.ts | 14 +++ .../delete-customer-group-customers.ts | 14 +++ .../src/customer-group/workflows/index.ts | 2 + .../[id]/customers/batch/route.ts | 27 ++++++ .../[id]/customers/remove/route.ts | 31 +++++++ .../customer-groups/[id]/customers/route.ts | 25 ++++++ .../admin/customer-groups/middlewares.ts | 38 +++++--- .../admin/customer-groups/validators.ts | 62 ++++++++++++- 14 files changed, 522 insertions(+), 12 deletions(-) create mode 100644 integration-tests/plugins/__tests__/customer-group/admin/batch-add-customers.ts create mode 100644 integration-tests/plugins/__tests__/customer-group/admin/batch-remove-customers.ts create mode 100644 integration-tests/plugins/__tests__/customer-group/admin/list-customer-group-customers.ts create mode 100644 packages/core-flows/src/customer-group/steps/create-customer-group-customers.ts create mode 100644 packages/core-flows/src/customer-group/steps/delete-customer-group-customers.ts create mode 100644 packages/core-flows/src/customer-group/workflows/create-customer-group-customers.ts create mode 100644 packages/core-flows/src/customer-group/workflows/delete-customer-group-customers.ts create mode 100644 packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/batch/route.ts create mode 100644 packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/remove/route.ts create mode 100644 packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/route.ts diff --git a/integration-tests/plugins/__tests__/customer-group/admin/batch-add-customers.ts b/integration-tests/plugins/__tests__/customer-group/admin/batch-add-customers.ts new file mode 100644 index 0000000000000..ed0efa1cce273 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/batch-add-customers.ts @@ -0,0 +1,85 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/customer-groups/:id/customers/batch", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should batch add customers to a group", async () => { + const api = useApi() as any + + const group = await customerModuleService.createCustomerGroup({ + name: "VIP", + }) + const customers = await customerModuleService.create([ + { + first_name: "Test", + last_name: "Test", + }, + { + first_name: "Test2", + last_name: "Test2", + }, + { + first_name: "Test3", + last_name: "Test3", + }, + ]) + + const response = await api.post( + `/admin/customer-groups/${group.id}/customers/batch`, + { + customer_ids: customers.map((c) => ({ id: c.id })), + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const updatedGroup = await customerModuleService.retrieveCustomerGroup( + group.id, + { + relations: ["customers"], + } + ) + expect(updatedGroup.customers?.length).toEqual(3) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer-group/admin/batch-remove-customers.ts b/integration-tests/plugins/__tests__/customer-group/admin/batch-remove-customers.ts new file mode 100644 index 0000000000000..a366dcd05fbb7 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/batch-remove-customers.ts @@ -0,0 +1,89 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("DELETE /admin/customer-groups/:id/customers/remove", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should batch delete customers from a group", async () => { + const api = useApi() as any + + const group = await customerModuleService.createCustomerGroup({ + name: "VIP", + }) + const customers = await customerModuleService.create([ + { + first_name: "Test", + last_name: "Test", + }, + { + first_name: "Test2", + last_name: "Test2", + }, + { + first_name: "Test3", + last_name: "Test3", + }, + ]) + + await customerModuleService.addCustomerToGroup( + customers.map((c) => ({ customer_id: c.id, customer_group_id: group.id })) + ) + + const response = await api.post( + `/admin/customer-groups/${group.id}/customers/remove`, + { + customer_ids: customers.map((c) => ({ id: c.id })), + }, + adminHeaders + ) + + expect(response.status).toEqual(200) + + const updatedGroup = await customerModuleService.retrieveCustomerGroup( + group.id, + { + relations: ["customers"], + } + ) + expect(updatedGroup.customers?.length).toEqual(0) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer-group/admin/list-customer-group-customers.ts b/integration-tests/plugins/__tests__/customer-group/admin/list-customer-group-customers.ts new file mode 100644 index 0000000000000..453170e8e12d7 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer-group/admin/list-customer-group-customers.ts @@ -0,0 +1,88 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("GET /admin/customer-groups/:id/customers", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should get all customer groups and its count", async () => { + const group = await customerModuleService.createCustomerGroup({ + name: "Test", + }) + + const customers = await customerModuleService.create([ + { + first_name: "Test", + last_name: "Test", + }, + { + first_name: "Test2", + last_name: "Test2", + }, + ]) + + // add to group + + await customerModuleService.addCustomerToGroup( + customers.map((c) => ({ customer_id: c.id, customer_group_id: group.id })) + ) + + const api = useApi() as any + + const response = await api.get( + `/admin/customer-groups/${group.id}/customers`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.count).toEqual(2) + expect(response.data.customers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: customers[0].id, + }), + expect.objectContaining({ + id: customers[1].id, + }), + ]) + ) + }) +}) diff --git a/packages/core-flows/src/customer-group/steps/create-customer-group-customers.ts b/packages/core-flows/src/customer-group/steps/create-customer-group-customers.ts new file mode 100644 index 0000000000000..cb31cf738d9ab --- /dev/null +++ b/packages/core-flows/src/customer-group/steps/create-customer-group-customers.ts @@ -0,0 +1,29 @@ +import { GroupCustomerPair, ICustomerModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const createCustomerGroupCustomersStepId = + "create-customer-group-customers" +export const createCustomerGroupCustomersStep = createStep( + createCustomerGroupCustomersStepId, + async (data: GroupCustomerPair[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const groupPairs = await service.addCustomerToGroup(data) + + return new StepResponse(groupPairs, data) + }, + async (groupPairs, { container }) => { + if (!groupPairs?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.removeCustomerFromGroup(groupPairs) + } +) diff --git a/packages/core-flows/src/customer-group/steps/delete-customer-group-customers.ts b/packages/core-flows/src/customer-group/steps/delete-customer-group-customers.ts new file mode 100644 index 0000000000000..b47aae66aafd7 --- /dev/null +++ b/packages/core-flows/src/customer-group/steps/delete-customer-group-customers.ts @@ -0,0 +1,28 @@ +import { GroupCustomerPair, ICustomerModuleService } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const deleteCustomerGroupCustomersStepId = + "delete-customer-group-customers" +export const deleteCustomerGroupCustomersStep = createStep( + deleteCustomerGroupCustomersStepId, + async (data: GroupCustomerPair[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.removeCustomerFromGroup(data) + + return new StepResponse(void 0, data) + }, + async (groupPairs, { container }) => { + if (!groupPairs?.length) { + return + } + const service = container.resolve( + ModuleRegistrationName.CUSTOMER + ) + + await service.addCustomerToGroup(groupPairs) + } +) diff --git a/packages/core-flows/src/customer-group/steps/index.ts b/packages/core-flows/src/customer-group/steps/index.ts index 2d99ae94fa022..196a19bd736da 100644 --- a/packages/core-flows/src/customer-group/steps/index.ts +++ b/packages/core-flows/src/customer-group/steps/index.ts @@ -1,3 +1,5 @@ export * from "./update-customer-groups" export * from "./delete-customer-groups" export * from "./create-customer-groups" +export * from "./create-customer-group-customers" +export * from "./delete-customer-group-customers" diff --git a/packages/core-flows/src/customer-group/workflows/create-customer-group-customers.ts b/packages/core-flows/src/customer-group/workflows/create-customer-group-customers.ts new file mode 100644 index 0000000000000..6a3da2d856f75 --- /dev/null +++ b/packages/core-flows/src/customer-group/workflows/create-customer-group-customers.ts @@ -0,0 +1,14 @@ +import { GroupCustomerPair } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { createCustomerGroupCustomersStep } from "../steps" + +type WorkflowInput = { groupCustomers: GroupCustomerPair[] } + +export const createCustomerGroupCustomersWorkflowId = + "create-customer-group-customers" +export const createCustomerGroupCustomersWorkflow = createWorkflow( + createCustomerGroupCustomersWorkflowId, + (input: WorkflowData): WorkflowData<{ id: string }[]> => { + return createCustomerGroupCustomersStep(input.groupCustomers) + } +) diff --git a/packages/core-flows/src/customer-group/workflows/delete-customer-group-customers.ts b/packages/core-flows/src/customer-group/workflows/delete-customer-group-customers.ts new file mode 100644 index 0000000000000..7696a36cc5f69 --- /dev/null +++ b/packages/core-flows/src/customer-group/workflows/delete-customer-group-customers.ts @@ -0,0 +1,14 @@ +import { GroupCustomerPair } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" +import { deleteCustomerGroupCustomersStep } from "../steps" + +type WorkflowInput = { groupCustomers: GroupCustomerPair[] } + +export const deleteCustomerGroupCustomersWorkflowId = + "delete-customer-group-customers" +export const deleteCustomerGroupCustomersWorkflow = createWorkflow( + deleteCustomerGroupCustomersWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteCustomerGroupCustomersStep(input.groupCustomers) + } +) diff --git a/packages/core-flows/src/customer-group/workflows/index.ts b/packages/core-flows/src/customer-group/workflows/index.ts index 2d99ae94fa022..196a19bd736da 100644 --- a/packages/core-flows/src/customer-group/workflows/index.ts +++ b/packages/core-flows/src/customer-group/workflows/index.ts @@ -1,3 +1,5 @@ export * from "./update-customer-groups" export * from "./delete-customer-groups" export * from "./create-customer-groups" +export * from "./create-customer-group-customers" +export * from "./delete-customer-group-customers" diff --git a/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/batch/route.ts b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/batch/route.ts new file mode 100644 index 0000000000000..d766dfb6e90aa --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/batch/route.ts @@ -0,0 +1,27 @@ +import { createCustomerGroupCustomersWorkflow } from "@medusajs/core-flows" +import { MedusaRequest, MedusaResponse } from "../../../../../../types/routing" +import { AdminPostCustomerGroupsGroupCustomersBatchReq } from "../../../validators" + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const { id } = req.params + const { customer_ids } = + req.validatedBody as AdminPostCustomerGroupsGroupCustomersBatchReq + + const createCustomers = createCustomerGroupCustomersWorkflow(req.scope) + + const { result, errors } = await createCustomers.run({ + input: { + groupCustomers: customer_ids.map((c) => ({ + customer_id: c.id, + customer_group_id: id, + })), + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ customer_group_customers: result }) +} diff --git a/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/remove/route.ts b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/remove/route.ts new file mode 100644 index 0000000000000..8e8647f733f67 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/remove/route.ts @@ -0,0 +1,31 @@ +import { deleteCustomerGroupCustomersWorkflow } from "@medusajs/core-flows" + +import { MedusaRequest, MedusaResponse } from "../../../../../../types/routing" +import { AdminPostCustomerGroupsGroupCustomersBatchReq } from "../../../validators" + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const { id } = req.params + const { customer_ids } = + req.validatedBody as AdminPostCustomerGroupsGroupCustomersBatchReq + + const deleteCustomers = deleteCustomerGroupCustomersWorkflow(req.scope) + + const { errors } = await deleteCustomers.run({ + input: { + groupCustomers: customer_ids.map((c) => ({ + customer_id: c.id, + customer_group_id: id, + })), + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + object: "customer_group_customers", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/route.ts b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/route.ts new file mode 100644 index 0000000000000..84a7fad2537c8 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/customer-groups/[id]/customers/route.ts @@ -0,0 +1,25 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService } from "@medusajs/types" +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const { id } = req.params + + const service = req.scope.resolve( + ModuleRegistrationName.CUSTOMER + ) + + const [customers, count] = await service.listAndCount( + { ...req.filterableFields, groups: id }, + req.listConfig + ) + + const { offset, limit } = req.validatedQuery + + res.json({ + count, + customers, + offset, + limit, + }) +} diff --git a/packages/medusa/src/api-v2/admin/customer-groups/middlewares.ts b/packages/medusa/src/api-v2/admin/customer-groups/middlewares.ts index d1ca416a9fc5b..e8faf2db43f45 100644 --- a/packages/medusa/src/api-v2/admin/customer-groups/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/customer-groups/middlewares.ts @@ -1,24 +1,18 @@ -import { MedusaV2Flag } from "@medusajs/utils" - -import { - isFeatureFlagEnabled, - transformBody, - transformQuery, -} from "../../../api/middlewares" +import { transformBody, transformQuery } from "../../../api/middlewares" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import * as QueryConfig from "./query-config" +import { listTransformQueryConfig as customersListTransformQueryConfig } from "../customers/query-config" import { AdminGetCustomerGroupsParams, AdminGetCustomerGroupsGroupParams, AdminPostCustomerGroupsReq, AdminPostCustomerGroupsGroupReq, + AdminGetCustomerGroupsGroupCustomersParams, + AdminPostCustomerGroupsGroupCustomersBatchReq, + AdminDeleteCustomerGroupsGroupCustomersBatchReq, } from "./validators" export const adminCustomerGroupRoutesMiddlewares: MiddlewareRoute[] = [ - { - matcher: "/admin/customer-groups*", - middlewares: [isFeatureFlagEnabled(MedusaV2Flag.key)], - }, { method: ["GET"], matcher: "/admin/customer-groups", @@ -49,4 +43,26 @@ export const adminCustomerGroupRoutesMiddlewares: MiddlewareRoute[] = [ matcher: "/admin/customer-groups/:id", middlewares: [transformBody(AdminPostCustomerGroupsGroupReq)], }, + { + method: ["GET"], + matcher: "/admin/customer-groups/:id/customers", + middlewares: [ + transformQuery( + AdminGetCustomerGroupsGroupCustomersParams, + customersListTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/customer-groups/:id/customers/batch", + middlewares: [transformBody(AdminPostCustomerGroupsGroupCustomersBatchReq)], + }, + { + method: ["POST"], + matcher: "/admin/customer-groups/:id/customers/remove", + middlewares: [ + transformBody(AdminDeleteCustomerGroupsGroupCustomersBatchReq), + ], + }, ] diff --git a/packages/medusa/src/api-v2/admin/customer-groups/validators.ts b/packages/medusa/src/api-v2/admin/customer-groups/validators.ts index a0f5a24283262..e318d6a38a2ca 100644 --- a/packages/medusa/src/api-v2/admin/customer-groups/validators.ts +++ b/packages/medusa/src/api-v2/admin/customer-groups/validators.ts @@ -1,5 +1,5 @@ import { OperatorMap } from "@medusajs/types" -import { Type } from "class-transformer" +import { Transform, Type } from "class-transformer" import { IsNotEmpty, IsOptional, @@ -8,6 +8,7 @@ import { } from "class-validator" import { FindParams, extendedFindParamsMixin } from "../../../types/common" import { OperatorMapValidator } from "../../../types/validators/operator-map" +import { IsType } from "../../../utils" export class AdminGetCustomerGroupsGroupParams extends FindParams {} @@ -112,3 +113,62 @@ export class AdminPostCustomerGroupsGroupReq { @IsOptional() name?: string } + +export class AdminGetCustomerGroupsGroupCustomersParams extends extendedFindParamsMixin( + { + limit: 100, + offset: 0, + } +) { + @IsOptional() + @IsString({ each: true }) + id?: string | string[] + + @IsOptional() + @IsType([String, [String], OperatorMapValidator]) + email?: string | string[] | OperatorMap + + @IsOptional() + @IsString({ each: true }) + company_name?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + first_name?: string | string[] | OperatorMap | null + + @IsOptional() + @IsType([String, [String], OperatorMapValidator]) + @Transform(({ value }) => (value === "null" ? null : value)) + last_name?: string | string[] | OperatorMap | null + + @IsOptional() + @IsString({ each: true }) + created_by?: string | string[] | null + + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + created_at?: OperatorMap + + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + updated_at?: OperatorMap +} + +class CustomerGroupsBatchCustomer { + @IsString() + id: string +} + +export class AdminDeleteCustomerGroupsGroupCustomersBatchReq { + @ValidateNested({ each: true }) + @Type(() => CustomerGroupsBatchCustomer) + customer_ids: CustomerGroupsBatchCustomer[] +} + +export class AdminPostCustomerGroupsGroupCustomersBatchReq { + @ValidateNested({ each: true }) + @Type(() => CustomerGroupsBatchCustomer) + customer_ids: CustomerGroupsBatchCustomer[] +} From 7903a15e0f3bb277c8513199a4d50123e1b28f5d Mon Sep 17 00:00:00 2001 From: Sebastian Rindom Date: Wed, 31 Jan 2024 12:58:29 +0100 Subject: [PATCH 10/11] feat(customer): Add create and retrieve customer from store side (#6267) **What** - GET /store/customers/me - POST /store/customers - Workflow for customer account creation - Authentication middleware on customer routes --- .../plugins/__tests__/cart/store/get-cart.ts | 2 + .../admin/create-customer-group.ts | 2 + .../admin/delete-customer-group.ts | 2 + .../admin/list-customer-groups.spec.ts | 2 + .../admin/retrieve-customer-group.ts | 2 + .../admin/update-customer-group.ts | 2 + .../admin/create-customer-addresses.ts | 2 + .../customer/admin/create-customer.ts | 2 + .../admin/delete-customer-address.spec.ts | 2 + .../customer/admin/delete-customer.ts | 2 + .../customer/admin/list-customer-addresses.ts | 2 + .../customer/admin/list-customers.spec.ts | 2 + .../admin/update-customer-address.spec.ts | 2 + .../customer/admin/update-customer.ts | 2 + .../customer/store/create-customer.spec.ts | 76 ++++++++++++++++++ .../__tests__/customer/store/get-me.spec.ts | 78 +++++++++++++++++++ integration-tests/plugins/medusa-config.js | 8 ++ integration-tests/plugins/package.json | 1 + .../src/migrations/Migration20240122041959.ts | 32 +++++--- packages/core-flows/src/auth/index.ts | 1 + packages/core-flows/src/auth/steps/index.ts | 1 + .../src/auth/steps/set-auth-app-metadata.ts | 59 ++++++++++++++ .../src/customer/steps/delete-customers.ts | 6 +- .../workflows/create-customer-account.ts | 31 ++++++++ .../customer/workflows/delete-customers.ts | 4 +- .../src/customer/workflows/index.ts | 1 + packages/medusa/src/api-v2/middlewares.ts | 2 + .../src/api-v2/store/customers/me/route.ts | 15 ++++ .../src/api-v2/store/customers/middlewares.ts | 28 +++++++ .../api-v2/store/customers/query-config.ts | 24 ++++++ .../src/api-v2/store/customers/route.ts | 14 ++++ .../src/api-v2/store/customers/validators.ts | 29 +++++++ yarn.lock | 3 +- 33 files changed, 423 insertions(+), 18 deletions(-) create mode 100644 integration-tests/plugins/__tests__/customer/store/create-customer.spec.ts create mode 100644 integration-tests/plugins/__tests__/customer/store/get-me.spec.ts create mode 100644 packages/core-flows/src/auth/index.ts create mode 100644 packages/core-flows/src/auth/steps/index.ts create mode 100644 packages/core-flows/src/auth/steps/set-auth-app-metadata.ts create mode 100644 packages/core-flows/src/customer/workflows/create-customer-account.ts create mode 100644 packages/medusa/src/api-v2/store/customers/me/route.ts create mode 100644 packages/medusa/src/api-v2/store/customers/middlewares.ts create mode 100644 packages/medusa/src/api-v2/store/customers/query-config.ts create mode 100644 packages/medusa/src/api-v2/store/customers/route.ts create mode 100644 packages/medusa/src/api-v2/store/customers/validators.ts diff --git a/integration-tests/plugins/__tests__/cart/store/get-cart.ts b/integration-tests/plugins/__tests__/cart/store/get-cart.ts index 0ce5e7870e579..8d2b64409c79d 100644 --- a/integration-tests/plugins/__tests__/cart/store/get-cart.ts +++ b/integration-tests/plugins/__tests__/cart/store/get-cart.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } describe("GET /store/:id", () => { diff --git a/integration-tests/plugins/__tests__/customer-group/admin/create-customer-group.ts b/integration-tests/plugins/__tests__/customer-group/admin/create-customer-group.ts index b987f255c8c0b..0c4fd5de014b3 100644 --- a/integration-tests/plugins/__tests__/customer-group/admin/create-customer-group.ts +++ b/integration-tests/plugins/__tests__/customer-group/admin/create-customer-group.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer-group/admin/delete-customer-group.ts b/integration-tests/plugins/__tests__/customer-group/admin/delete-customer-group.ts index 88189e4402fab..106cf0272dba4 100644 --- a/integration-tests/plugins/__tests__/customer-group/admin/delete-customer-group.ts +++ b/integration-tests/plugins/__tests__/customer-group/admin/delete-customer-group.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer-group/admin/list-customer-groups.spec.ts b/integration-tests/plugins/__tests__/customer-group/admin/list-customer-groups.spec.ts index 732f26da349a2..2790bc17a9124 100644 --- a/integration-tests/plugins/__tests__/customer-group/admin/list-customer-groups.spec.ts +++ b/integration-tests/plugins/__tests__/customer-group/admin/list-customer-groups.spec.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer-group/admin/retrieve-customer-group.ts b/integration-tests/plugins/__tests__/customer-group/admin/retrieve-customer-group.ts index f2ad1a6634bd3..17bfa8493d0e7 100644 --- a/integration-tests/plugins/__tests__/customer-group/admin/retrieve-customer-group.ts +++ b/integration-tests/plugins/__tests__/customer-group/admin/retrieve-customer-group.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer-group/admin/update-customer-group.ts b/integration-tests/plugins/__tests__/customer-group/admin/update-customer-group.ts index 25d94f0b0b317..64d28fcccabc3 100644 --- a/integration-tests/plugins/__tests__/customer-group/admin/update-customer-group.ts +++ b/integration-tests/plugins/__tests__/customer-group/admin/update-customer-group.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts b/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts index c860f237c53cb..5aed39eeaacd5 100644 --- a/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts +++ b/integration-tests/plugins/__tests__/customer/admin/create-customer-addresses.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer/admin/create-customer.ts b/integration-tests/plugins/__tests__/customer/admin/create-customer.ts index 1a11e54234933..a62b88615f6d8 100644 --- a/integration-tests/plugins/__tests__/customer/admin/create-customer.ts +++ b/integration-tests/plugins/__tests__/customer/admin/create-customer.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer/admin/delete-customer-address.spec.ts b/integration-tests/plugins/__tests__/customer/admin/delete-customer-address.spec.ts index a604b2b021dc0..946deec07b171 100644 --- a/integration-tests/plugins/__tests__/customer/admin/delete-customer-address.spec.ts +++ b/integration-tests/plugins/__tests__/customer/admin/delete-customer-address.spec.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts b/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts index 0d1c032c14539..77192b966ea49 100644 --- a/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts +++ b/integration-tests/plugins/__tests__/customer/admin/delete-customer.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer/admin/list-customer-addresses.ts b/integration-tests/plugins/__tests__/customer/admin/list-customer-addresses.ts index b87ac4e59250e..842e6f9fcda63 100644 --- a/integration-tests/plugins/__tests__/customer/admin/list-customer-addresses.ts +++ b/integration-tests/plugins/__tests__/customer/admin/list-customer-addresses.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer/admin/list-customers.spec.ts b/integration-tests/plugins/__tests__/customer/admin/list-customers.spec.ts index a5c0c47d541aa..a1e9aac30f119 100644 --- a/integration-tests/plugins/__tests__/customer/admin/list-customers.spec.ts +++ b/integration-tests/plugins/__tests__/customer/admin/list-customers.spec.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts b/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts index 4ce6fac25d715..10e5d3b1d9d0d 100644 --- a/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts +++ b/integration-tests/plugins/__tests__/customer/admin/update-customer-address.spec.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer/admin/update-customer.ts b/integration-tests/plugins/__tests__/customer/admin/update-customer.ts index 92b8c9da32114..4aeda967bf429 100644 --- a/integration-tests/plugins/__tests__/customer/admin/update-customer.ts +++ b/integration-tests/plugins/__tests__/customer/admin/update-customer.ts @@ -7,6 +7,8 @@ import { getContainer } from "../../../../environment-helpers/use-container" import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" +jest.setTimeout(50000) + const env = { MEDUSA_FF_MEDUSA_V2: true } const adminHeaders = { headers: { "x-medusa-access-token": "test_token" }, diff --git a/integration-tests/plugins/__tests__/customer/store/create-customer.spec.ts b/integration-tests/plugins/__tests__/customer/store/create-customer.spec.ts new file mode 100644 index 0000000000000..6b986b0bc04b6 --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/store/create-customer.spec.ts @@ -0,0 +1,76 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService, IAuthModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } + +describe("POST /store/customers", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should create a customer", async () => { + const authService: IAuthModuleService = appContainer.resolve( + ModuleRegistrationName.AUTH + ) + const authUser = await authService.createAuthUser({ + entity_id: "store_user", + provider_id: "test", + }) + const jwt = await authService.generateJwtToken(authUser.id, "store") + + const api = useApi() as any + const response = await api.post( + `/store/customers`, + { + first_name: "John", + last_name: "Doe", + email: "john@me.com", + }, + { headers: { authorization: `Bearer ${jwt}` } } + ) + + expect(response.status).toEqual(200) + expect(response.data.customer).toEqual( + expect.objectContaining({ + id: expect.any(String), + first_name: "John", + last_name: "Doe", + email: "john@me.com", + }) + ) + }) +}) diff --git a/integration-tests/plugins/__tests__/customer/store/get-me.spec.ts b/integration-tests/plugins/__tests__/customer/store/get-me.spec.ts new file mode 100644 index 0000000000000..24dc30af8715a --- /dev/null +++ b/integration-tests/plugins/__tests__/customer/store/get-me.spec.ts @@ -0,0 +1,78 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { ICustomerModuleService, IAuthModuleService } from "@medusajs/types" +import path from "path" +import { startBootstrapApp } from "../../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../../environment-helpers/use-api" +import { getContainer } from "../../../../environment-helpers/use-container" +import { initDb, useDb } from "../../../../environment-helpers/use-db" +import adminSeeder from "../../../../helpers/admin-seeder" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } + +describe("GET /store/customers", () => { + let dbConnection + let appContainer + let shutdownServer + let customerModuleService: ICustomerModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + customerModuleService = appContainer.resolve( + ModuleRegistrationName.CUSTOMER + ) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + beforeEach(async () => { + await adminSeeder(dbConnection) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should retrieve auth user's customer", async () => { + const customer = await customerModuleService.create({ + first_name: "John", + last_name: "Doe", + email: "john@me.com", + }) + + const authService: IAuthModuleService = appContainer.resolve( + ModuleRegistrationName.AUTH + ) + const authUser = await authService.createAuthUser({ + entity_id: "store_user", + provider_id: "test", + app_metadata: { customer_id: customer.id }, + }) + + const jwt = await authService.generateJwtToken(authUser.id, "store") + + const api = useApi() as any + const response = await api.get(`/store/customers/me`, { + headers: { authorization: `Bearer ${jwt}` }, + }) + + expect(response.status).toEqual(200) + expect(response.data.customer).toEqual( + expect.objectContaining({ + id: expect.any(String), + first_name: "John", + last_name: "Doe", + email: "john@me.com", + }) + ) + }) +}) diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index 41dffcbef92be..5d4193b4a4d79 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -42,6 +42,14 @@ module.exports = { }, }, modules: { + [Modules.AUTH]: { + scope: "internal", + resources: "shared", + resolve: "@medusajs/auth", + options: { + jwt_secret: "test", + }, + }, [Modules.STOCK_LOCATION]: { scope: "internal", resources: "shared", diff --git a/integration-tests/plugins/package.json b/integration-tests/plugins/package.json index 74a9ec209c1f1..17c355023ecc4 100644 --- a/integration-tests/plugins/package.json +++ b/integration-tests/plugins/package.json @@ -9,6 +9,7 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { + "@medusajs/auth": "workspace:*", "@medusajs/cache-inmemory": "workspace:*", "@medusajs/customer": "workspace:^", "@medusajs/event-bus-local": "workspace:*", diff --git a/packages/auth/src/migrations/Migration20240122041959.ts b/packages/auth/src/migrations/Migration20240122041959.ts index d23f27a10750e..15f1526572d4f 100644 --- a/packages/auth/src/migrations/Migration20240122041959.ts +++ b/packages/auth/src/migrations/Migration20240122041959.ts @@ -1,22 +1,30 @@ -import { Migration } from '@mikro-orm/migrations'; +import { Migration } from "@mikro-orm/migrations" export class Migration20240122041959 extends Migration { - async up(): Promise { - this.addSql('create table if not exists "auth_provider" ("provider" text not null, "name" text not null, "domain" text check ("domain" in (\'all\', \'store\', \'admin\')) not null default \'all\', "config" jsonb null, "is_active" boolean not null default false, constraint "auth_provider_pkey" primary key ("provider"));'); - - this.addSql('create table if not exists "auth_user" ("id" text not null, "entity_id" text not null, "provider_id" text null, "user_metadata" jsonb null, "app_metadata" jsonb null, "provider_metadata" jsonb null, constraint "auth_user_pkey" primary key ("id"));'); - this.addSql('alter table "auth_user" add constraint "IDX_auth_user_provider_entity_id" unique ("provider_id", "entity_id");'); - - this.addSql('alter table "auth_user" add constraint if not exists "auth_user_provider_id_foreign" foreign key ("provider_id") references "auth_provider" ("provider") on delete cascade;'); + this.addSql( + 'create table if not exists "auth_provider" ("provider" text not null, "name" text not null, "domain" text check ("domain" in (\'all\', \'store\', \'admin\')) not null default \'all\', "config" jsonb null, "is_active" boolean not null default false, constraint "auth_provider_pkey" primary key ("provider"));' + ) + + this.addSql( + 'create table if not exists "auth_user" ("id" text not null, "entity_id" text not null, "provider_id" text null, "user_metadata" jsonb null, "app_metadata" jsonb null, "provider_metadata" jsonb null, constraint "auth_user_pkey" primary key ("id"));' + ) + this.addSql( + 'alter table "auth_user" add constraint "IDX_auth_user_provider_entity_id" unique ("provider_id", "entity_id");' + ) + + this.addSql( + 'alter table "auth_user" add constraint "auth_user_provider_id_foreign" foreign key ("provider_id") references "auth_provider" ("provider") on delete cascade;' + ) } async down(): Promise { - this.addSql('alter table "auth_user" drop constraint if exists "auth_user_provider_id_foreign";'); + this.addSql( + 'alter table "auth_user" drop constraint if exists "auth_user_provider_id_foreign";' + ) - this.addSql('drop table if exists "auth_provider" cascade;'); + this.addSql('drop table if exists "auth_provider" cascade;') - this.addSql('drop table if exists "auth_user" cascade;'); + this.addSql('drop table if exists "auth_user" cascade;') } - } diff --git a/packages/core-flows/src/auth/index.ts b/packages/core-flows/src/auth/index.ts new file mode 100644 index 0000000000000..c1f49c23fa343 --- /dev/null +++ b/packages/core-flows/src/auth/index.ts @@ -0,0 +1 @@ +export * from "./steps" diff --git a/packages/core-flows/src/auth/steps/index.ts b/packages/core-flows/src/auth/steps/index.ts new file mode 100644 index 0000000000000..5c0c85f9a084e --- /dev/null +++ b/packages/core-flows/src/auth/steps/index.ts @@ -0,0 +1 @@ +export * from "./set-auth-app-metadata" diff --git a/packages/core-flows/src/auth/steps/set-auth-app-metadata.ts b/packages/core-flows/src/auth/steps/set-auth-app-metadata.ts new file mode 100644 index 0000000000000..7b5d9809d463f --- /dev/null +++ b/packages/core-flows/src/auth/steps/set-auth-app-metadata.ts @@ -0,0 +1,59 @@ +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { IAuthModuleService } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { isDefined } from "@medusajs/utils" + +type StepInput = { + authUserId: string + key: string + value: string +} + +export const setAuthAppMetadataStepId = "set-auth-app-metadata" +export const setAuthAppMetadataStep = createStep( + setAuthAppMetadataStepId, + async (data: StepInput, { container }) => { + const service = container.resolve( + ModuleRegistrationName.AUTH + ) + + const authUser = await service.retrieveAuthUser(data.authUserId) + + const appMetadata = authUser.app_metadata || {} + if (isDefined(appMetadata[data.key])) { + throw new Error(`Key ${data.key} already exists in app metadata`) + } + + appMetadata[data.key] = data.value + + await service.updateAuthUser({ + id: authUser.id, + app_metadata: appMetadata, + }) + + return new StepResponse(authUser, { id: authUser.id, key: data.key }) + }, + async (idAndKey, { container }) => { + if (!idAndKey) { + return + } + + const { id, key } = idAndKey + + const service = container.resolve( + ModuleRegistrationName.AUTH + ) + + const authUser = await service.retrieveAuthUser(id) + + const appMetadata = authUser.app_metadata || {} + if (isDefined(appMetadata[key])) { + delete appMetadata[key] + } + + await service.updateAuthUser({ + id: authUser.id, + app_metadata: appMetadata, + }) + } +) diff --git a/packages/core-flows/src/customer/steps/delete-customers.ts b/packages/core-flows/src/customer/steps/delete-customers.ts index 35b6947c73d1e..c2917d26c8542 100644 --- a/packages/core-flows/src/customer/steps/delete-customers.ts +++ b/packages/core-flows/src/customer/steps/delete-customers.ts @@ -4,9 +4,9 @@ import { ModuleRegistrationName } from "@medusajs/modules-sdk" type DeleteCustomerStepInput = string[] -export const deleteCustomerStepId = "delete-customer" -export const deleteCustomerStep = createStep( - deleteCustomerStepId, +export const deleteCustomersStepId = "delete-customers" +export const deleteCustomersStep = createStep( + deleteCustomersStepId, async (ids: DeleteCustomerStepInput, { container }) => { const service = container.resolve( ModuleRegistrationName.CUSTOMER diff --git a/packages/core-flows/src/customer/workflows/create-customer-account.ts b/packages/core-flows/src/customer/workflows/create-customer-account.ts new file mode 100644 index 0000000000000..7dd8007ce1476 --- /dev/null +++ b/packages/core-flows/src/customer/workflows/create-customer-account.ts @@ -0,0 +1,31 @@ +import { CreateCustomerDTO, CustomerDTO } from "@medusajs/types" +import { createWorkflow, WorkflowData } from "@medusajs/workflows-sdk" +import { createCustomersStep } from "../steps" +import { setAuthAppMetadataStep } from "../../auth/steps" +import { transform } from "@medusajs/workflows-sdk" + +type WorkflowInput = { + authUserId: string + customersData: CreateCustomerDTO +} + +export const createCustomerAccountWorkflowId = "create-customer-account" +export const createCustomerAccountWorkflow = createWorkflow( + createCustomerAccountWorkflowId, + (input: WorkflowData): WorkflowData => { + const customers = createCustomersStep([input.customersData]) + + const customer = transform( + customers, + (customers: CustomerDTO[]) => customers[0] + ) + + setAuthAppMetadataStep({ + authUserId: input.authUserId, + key: "customer_id", + value: customer.id, + }) + + return customer + } +) diff --git a/packages/core-flows/src/customer/workflows/delete-customers.ts b/packages/core-flows/src/customer/workflows/delete-customers.ts index 7603d4daec2bf..2af349498d89a 100644 --- a/packages/core-flows/src/customer/workflows/delete-customers.ts +++ b/packages/core-flows/src/customer/workflows/delete-customers.ts @@ -1,5 +1,5 @@ import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" -import { deleteCustomerStep } from "../steps" +import { deleteCustomersStep } from "../steps" type WorkflowInput = { ids: string[] } @@ -7,6 +7,6 @@ export const deleteCustomersWorkflowId = "delete-customers" export const deleteCustomersWorkflow = createWorkflow( deleteCustomersWorkflowId, (input: WorkflowData): WorkflowData => { - return deleteCustomerStep(input.ids) + return deleteCustomersStep(input.ids) } ) diff --git a/packages/core-flows/src/customer/workflows/index.ts b/packages/core-flows/src/customer/workflows/index.ts index cf2fe8e9b0cac..97c7505c37bff 100644 --- a/packages/core-flows/src/customer/workflows/index.ts +++ b/packages/core-flows/src/customer/workflows/index.ts @@ -1,6 +1,7 @@ export * from "./create-customers" export * from "./update-customers" export * from "./delete-customers" +export * from "./create-customer-account" export * from "./create-addresses" export * from "./update-addresses" export * from "./delete-addresses" diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index 34fabd658f811..40b80fa5ee3f2 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -1,6 +1,7 @@ import { MiddlewaresConfig } from "../loaders/helpers/routing/types" import { adminCampaignRoutesMiddlewares } from "./admin/campaigns/middlewares" import { adminCustomerGroupRoutesMiddlewares } from "./admin/customer-groups/middlewares" +import { storeCustomerRoutesMiddlewares } from "./store/customers/middlewares" import { adminCustomerRoutesMiddlewares } from "./admin/customers/middlewares" import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" import { storeCartRoutesMiddlewares } from "./store/carts/middlewares" @@ -11,6 +12,7 @@ export const config: MiddlewaresConfig = { ...adminCustomerRoutesMiddlewares, ...adminPromotionRoutesMiddlewares, ...adminCampaignRoutesMiddlewares, + ...storeCustomerRoutesMiddlewares, ...storeCartRoutesMiddlewares, ], } diff --git a/packages/medusa/src/api-v2/store/customers/me/route.ts b/packages/medusa/src/api-v2/store/customers/me/route.ts new file mode 100644 index 0000000000000..22a9746d97428 --- /dev/null +++ b/packages/medusa/src/api-v2/store/customers/me/route.ts @@ -0,0 +1,15 @@ +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { MedusaRequest, MedusaResponse } from "../../../../types/routing" + +export const GET = async (req: MedusaRequest, res: MedusaResponse) => { + const id = req.auth_user!.app_metadata.customer_id + + const customerModule = req.scope.resolve(ModuleRegistrationName.CUSTOMER) + + const customer = await customerModule.retrieve(id, { + select: req.retrieveConfig.select, + relations: req.retrieveConfig.relations, + }) + + res.json({ customer }) +} diff --git a/packages/medusa/src/api-v2/store/customers/middlewares.ts b/packages/medusa/src/api-v2/store/customers/middlewares.ts new file mode 100644 index 0000000000000..b42ff3a60a730 --- /dev/null +++ b/packages/medusa/src/api-v2/store/customers/middlewares.ts @@ -0,0 +1,28 @@ +import { transformBody, transformQuery } from "../../../api/middlewares" +import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" +import { StorePostCustomersReq, StoreGetCustomersMeParams } from "./validators" +import authenticate from "../../../utils/authenticate-middleware" +import * as QueryConfig from "./query-config" + +export const storeCustomerRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: "ALL", + matcher: "/store/customers*", + middlewares: [authenticate("store", ["session", "bearer"])], + }, + { + method: ["POST"], + matcher: "/store/customers", + middlewares: [transformBody(StorePostCustomersReq)], + }, + { + method: ["GET"], + matcher: "/store/customers/me", + middlewares: [ + transformQuery( + StoreGetCustomersMeParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, +] diff --git a/packages/medusa/src/api-v2/store/customers/query-config.ts b/packages/medusa/src/api-v2/store/customers/query-config.ts new file mode 100644 index 0000000000000..8a1828b5e7ccb --- /dev/null +++ b/packages/medusa/src/api-v2/store/customers/query-config.ts @@ -0,0 +1,24 @@ +import { CustomerDTO } from "@medusajs/types" + +export const defaultStoreCustomersRelations = [] +export const allowedStoreCustomersRelations = ["addresses", "groups"] +export const defaultStoreCustomersFields: (keyof CustomerDTO)[] = [ + "id", + "email", + "company_name", + "first_name", + "last_name", + "phone", + "metadata", + "created_by", + "deleted_at", + "created_at", + "updated_at", +] + +export const retrieveTransformQueryConfig = { + defaultFields: defaultStoreCustomersFields, + defaultRelations: defaultStoreCustomersRelations, + allowedRelations: allowedStoreCustomersRelations, + isList: false, +} diff --git a/packages/medusa/src/api-v2/store/customers/route.ts b/packages/medusa/src/api-v2/store/customers/route.ts new file mode 100644 index 0000000000000..b7600fc55dd78 --- /dev/null +++ b/packages/medusa/src/api-v2/store/customers/route.ts @@ -0,0 +1,14 @@ +import { MedusaRequest, MedusaResponse } from "../../../types/routing" +import { createCustomerAccountWorkflow } from "@medusajs/core-flows" +import { CreateCustomerDTO } from "@medusajs/types" + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const createCustomers = createCustomerAccountWorkflow(req.scope) + const customersData = req.validatedBody as CreateCustomerDTO + + const { result } = await createCustomers.run({ + input: { customersData, authUserId: req.auth_user!.id }, + }) + + res.status(200).json({ customer: result }) +} diff --git a/packages/medusa/src/api-v2/store/customers/validators.ts b/packages/medusa/src/api-v2/store/customers/validators.ts new file mode 100644 index 0000000000000..c32d2634b55ee --- /dev/null +++ b/packages/medusa/src/api-v2/store/customers/validators.ts @@ -0,0 +1,29 @@ +import { IsEmail, IsObject, IsOptional, IsString } from "class-validator" +import { FindParams } from "../../../types/common" + +export class StoreGetCustomersMeParams extends FindParams {} + +export class StorePostCustomersReq { + @IsString() + @IsOptional() + first_name: string + + @IsString() + @IsOptional() + last_name: string + + @IsEmail() + email: string + + @IsString() + @IsOptional() + phone?: string + + @IsString() + @IsOptional() + company_name?: string + + @IsObject() + @IsOptional() + metadata?: Record +} diff --git a/yarn.lock b/yarn.lock index 7a53985a90a1f..5775225d010e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7889,7 +7889,7 @@ __metadata: languageName: unknown linkType: soft -"@medusajs/auth@workspace:packages/auth": +"@medusajs/auth@workspace:*, @medusajs/auth@workspace:packages/auth": version: 0.0.0-use.local resolution: "@medusajs/auth@workspace:packages/auth" dependencies: @@ -31377,6 +31377,7 @@ __metadata: "@babel/cli": ^7.12.10 "@babel/core": ^7.12.10 "@babel/node": ^7.12.10 + "@medusajs/auth": "workspace:*" "@medusajs/cache-inmemory": "workspace:*" "@medusajs/customer": "workspace:^" "@medusajs/event-bus-local": "workspace:*" From e749dd653c755bfc3632b134d32c15ceaee0a852 Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Wed, 31 Jan 2024 13:30:33 +0100 Subject: [PATCH 11/11] feat(types): added computed actions for automatic promotions (#6272) what: - compute actions account for automatic promotions (RESOLVES CORE-1701) RESOLVES CORE-1689 --- .changeset/perfect-spies-hug.md | 5 + .../promotion-module/compute-actions.spec.ts | 450 +++++++++++++++++- .../src/services/promotion-module.ts | 17 +- .../src/promotion/common/compute-actions.ts | 4 + 4 files changed, 470 insertions(+), 6 deletions(-) create mode 100644 .changeset/perfect-spies-hug.md diff --git a/.changeset/perfect-spies-hug.md b/.changeset/perfect-spies-hug.md new file mode 100644 index 0000000000000..16f08ba6fd290 --- /dev/null +++ b/.changeset/perfect-spies-hug.md @@ -0,0 +1,5 @@ +--- +"@medusajs/types": patch +--- + +feat(types): added computed actions for automatic promotions diff --git a/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts index 09c7cdcaaf68d..ee44ad54453b3 100644 --- a/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts +++ b/packages/promotion/integration-tests/__tests__/services/promotion-module/compute-actions.spec.ts @@ -1,11 +1,11 @@ +import { Modules } from "@medusajs/modules-sdk" import { IPromotionModuleService } from "@medusajs/types" import { PromotionType } from "@medusajs/utils" import { SqlEntityManager } from "@mikro-orm/postgresql" +import { initModules } from "medusa-test-utils" import { createCampaigns } from "../../../__fixtures__/campaigns" import { MikroOrmWrapper } from "../../../utils" import { getInitModuleConfig } from "../../../utils/get-init-module-config" -import { Modules } from "@medusajs/modules-sdk" -import { initModules } from "medusa-test-utils" jest.setTimeout(30000) @@ -239,6 +239,84 @@ describe("Promotion Service: computeActions", () => { ]) }) + it("should compute the correct item amendments when promotion is automatic", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + is_automatic: true, + type: PromotionType.STANDARD, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "each", + value: "200", + max_quantity: 1, + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions([], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 5, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 100, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { const [createdPromotion] = await service.create([ { @@ -666,6 +744,83 @@ describe("Promotion Service: computeActions", () => { ]) }) + it("should compute the correct item amendments when promotion is automatic", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + is_automatic: true, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "items", + allocation: "across", + value: "400", + target_rules: [ + { + attribute: "product_category.id", + operator: "eq", + values: ["catg_cotton"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions([], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 2, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 300, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 100, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 300, + code: "PROMOTION_TEST", + }, + ]) + }) + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { const [createdPromotion] = await service.create([ { @@ -1099,6 +1254,151 @@ describe("Promotion Service: computeActions", () => { ]) }) + it("should compute the correct shipping_method amendments when promotion is automatic", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + is_automatic: true, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions([], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 250, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 150, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 200, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + + it("should compute the correct shipping_method amendments when promotion is automatic and prevent_auto_promotions is false", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + is_automatic: true, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "each", + value: "200", + max_quantity: 2, + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions( + [], + { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 250, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 150, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + }, + { prevent_auto_promotions: true } + ) + + expect(result).toEqual([]) + }) + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { const [createdPromotion] = await service.create([ { @@ -1509,6 +1809,82 @@ describe("Promotion Service: computeActions", () => { ]) }) + it("should compute the correct shipping_method amendments when promotion is automatic", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + is_automatic: true, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "shipping_methods", + allocation: "across", + value: "200", + target_rules: [ + { + attribute: "shipping_option.id", + operator: "in", + values: ["express", "standard"], + }, + ], + }, + }, + ]) + + const result = await service.computeActions([], { + customer: { + customer_group: { + id: "VIP", + }, + }, + shipping_methods: [ + { + id: "shipping_method_express", + unit_price: 500, + shipping_option: { + id: "express", + }, + }, + { + id: "shipping_method_standard", + unit_price: 100, + shipping_option: { + id: "standard", + }, + }, + { + id: "shipping_method_snail", + unit_price: 200, + shipping_option: { + id: "snail", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_express", + amount: 166.66666666666669, + code: "PROMOTION_TEST", + }, + { + action: "addShippingMethodAdjustment", + shipping_method_id: "shipping_method_standard", + amount: 33.33333333333333, + code: "PROMOTION_TEST", + }, + ]) + }) + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { const [createdPromotion] = await service.create([ { @@ -1913,6 +2289,76 @@ describe("Promotion Service: computeActions", () => { ]) }) + it("should compute the correct item amendments when promotion is automatic", async () => { + const [createdPromotion] = await service.create([ + { + code: "PROMOTION_TEST", + type: PromotionType.STANDARD, + is_automatic: true, + rules: [ + { + attribute: "customer.customer_group.id", + operator: "in", + values: ["VIP", "top100"], + }, + ], + application_method: { + type: "fixed", + target_type: "order", + value: "200", + max_quantity: 2, + }, + }, + ]) + + const result = await service.computeActions([], { + customer: { + customer_group: { + id: "VIP", + }, + }, + items: [ + { + id: "item_cotton_tshirt", + quantity: 1, + unit_price: 100, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_tshirt", + }, + }, + { + id: "item_cotton_sweater", + quantity: 2, + unit_price: 150, + product_category: { + id: "catg_cotton", + }, + product: { + id: "prod_sweater", + }, + }, + ], + }) + + expect(result).toEqual([ + { + action: "addItemAdjustment", + item_id: "item_cotton_tshirt", + amount: 50, + code: "PROMOTION_TEST", + }, + { + action: "addItemAdjustment", + item_id: "item_cotton_sweater", + amount: 150, + code: "PROMOTION_TEST", + }, + ]) + }) + it("should compute the correct item amendments when there are multiple promotions to apply", async () => { const [createdPromotion] = await service.create([ { diff --git a/packages/promotion/src/services/promotion-module.ts b/packages/promotion/src/services/promotion-module.ts index ecdc1b5113ef9..dfa05102423a0 100644 --- a/packages/promotion/src/services/promotion-module.ts +++ b/packages/promotion/src/services/promotion-module.ts @@ -216,11 +216,11 @@ export default class PromotionModuleService< } async computeActions( - promotionCodesToApply: string[], + promotionCodes: string[], applicationContext: PromotionTypes.ComputeActionContext, - // TODO: specify correct type with options - options: Record = {} + options: PromotionTypes.ComputeActionOptions = {} ): Promise { + const { prevent_auto_promotions: preventAutoPromotions } = options const computedActions: PromotionTypes.ComputeActions[] = [] const { items = [], shipping_methods: shippingMethods = [] } = applicationContext @@ -231,6 +231,15 @@ export default class PromotionModuleService< PromotionTypes.ComputeActionAdjustmentLine >() const methodIdPromoValueMap = new Map() + const automaticPromotions = preventAutoPromotions + ? [] + : await this.list({ is_automatic: true }, { select: ["code"] }) + + const automaticPromotionCodes = automaticPromotions.map((p) => p.code!) + const promotionCodesToApply = [ + ...promotionCodes, + ...automaticPromotionCodes, + ] items.forEach((item) => { item.adjustments?.forEach((adjustment) => { @@ -291,7 +300,7 @@ export default class PromotionModuleService< ) } - if (promotionCodesToApply.includes(appliedCode)) { + if (promotionCodes.includes(appliedCode)) { continue } diff --git a/packages/types/src/promotion/common/compute-actions.ts b/packages/types/src/promotion/common/compute-actions.ts index b1f97a3c0c44c..267da4b5c38d9 100644 --- a/packages/types/src/promotion/common/compute-actions.ts +++ b/packages/types/src/promotion/common/compute-actions.ts @@ -65,3 +65,7 @@ export interface ComputeActionContext extends Record { items?: ComputeActionItemLine[] shipping_methods?: ComputeActionShippingLine[] } + +export interface ComputeActionOptions { + prevent_auto_promotions?: boolean +}