From bfb81b8b32fbba538010221f1a30fcc31aaa691e Mon Sep 17 00:00:00 2001 From: adrien2p Date: Thu, 14 Apr 2022 18:33:04 +0200 Subject: [PATCH 01/92] feat(medusa): Improve base-service --- .../interfaces/__tests__/base-service.spec.ts | 6 +- .../medusa/src/interfaces/base-service.ts | 89 +++++++++---------- 2 files changed, 45 insertions(+), 50 deletions(-) diff --git a/packages/medusa/src/interfaces/__tests__/base-service.spec.ts b/packages/medusa/src/interfaces/__tests__/base-service.spec.ts index 9166759fac..9fa804e4f1 100644 --- a/packages/medusa/src/interfaces/__tests__/base-service.spec.ts +++ b/packages/medusa/src/interfaces/__tests__/base-service.spec.ts @@ -1,4 +1,4 @@ -import BaseService from "../base-service" +import { BaseService } from "../base-service" import { In, Not } from "typeorm" import { MockManager } from "medusa-test-utils" @@ -6,7 +6,7 @@ describe("BaseService", () => { it("should cloned the child class withTransaction", () => { class Child extends BaseService { constructor(protected readonly container) { - super(container, {}); + super(container); this.container = container } @@ -58,4 +58,4 @@ describe("BaseService", () => { }) }) }) -}) +}) \ No newline at end of file diff --git a/packages/medusa/src/interfaces/base-service.ts b/packages/medusa/src/interfaces/base-service.ts index 18560465f2..d606da89e7 100644 --- a/packages/medusa/src/interfaces/base-service.ts +++ b/packages/medusa/src/interfaces/base-service.ts @@ -1,7 +1,7 @@ import { MedusaError } from "medusa-core-utils" import { EntityManager, FindOperator, In, Raw } from "typeorm" import { IsolationLevel } from "typeorm/driver/types/IsolationLevel" -import { FindConfig } from "../types/common" +import { FindConfig, Writable } from "../types/common" type Selector = { [key in keyof TEntity]?: unknown } @@ -9,7 +9,7 @@ type Selector = { [key in keyof TEntity]?: unknown } * Common functionality for Services * @interface */ -class BaseService< +export class BaseService< TChild extends BaseService, TContainer = unknown > { @@ -19,22 +19,17 @@ class BaseService< constructor( container: TContainer, - protected readonly configModule: Record + protected readonly configModule?: Record ) { this.container_ = container } - withTransaction(): this - withTransaction(transactionManager: EntityManager): TChild withTransaction(transactionManager?: EntityManager): this | TChild { if (!transactionManager) { return this } - const cloned = new (this.constructor)< - TChild, - TContainer - >( + const cloned = new (this.constructor)( { ...this.container_, manager: transactionManager, @@ -57,12 +52,12 @@ class BaseService< selector: Selector, config: FindConfig = {} ): FindConfig & { - where: { [key in keyof TEntity]?: unknown } + where: Partial> withDeleted?: boolean } { const build = ( obj: Record - ): { [key in keyof TEntity]?: unknown } => { + ): Partial> => { return Object.entries(obj).reduce((acc, [key, value]: any) => { // Undefined values indicate that they have no significance to the query. // If the query is looking for rows where a column is not set it should use null instead of undefined @@ -83,27 +78,25 @@ class BaseService< acc[key] = In([...(value as unknown[])]) break case value !== null && typeof value === "object": - Object.entries(value as Record).map( - ([modifier, val]) => { - switch (modifier) { - case "lt": - subquery.push({ operator: "<", value: val }) - break - case "gt": - subquery.push({ operator: ">", value: val }) - break - case "lte": - subquery.push({ operator: "<=", value: val }) - break - case "gte": - subquery.push({ operator: ">=", value: val }) - break - default: - acc[key] = value - break - } + Object.entries(value).map(([modifier, val]) => { + switch (modifier) { + case "lt": + subquery.push({ operator: "<", value: val }) + break + case "gt": + subquery.push({ operator: ">", value: val }) + break + case "lte": + subquery.push({ operator: "<=", value: val }) + break + case "gte": + subquery.push({ operator: ">=", value: val }) + break + default: + acc[key] = value + break } - ) + }) if (subquery.length) { acc[key] = Raw( @@ -121,11 +114,11 @@ class BaseService< } return acc - }, {} as { [key in keyof TEntity]?: unknown }) + }, {} as Partial>) } const query: FindConfig & { - where: { [key in keyof TEntity]?: unknown } + where: Partial> withDeleted?: boolean } = { where: build(selector), @@ -217,17 +210,19 @@ class BaseService< * @param maybeErrorHandlerOrDontFail Potential error handler * @return the result of the transactional work */ - async atomicPhase_( - work: (transactionManager: EntityManager) => Promise, + async atomicPhase_( + work: (transactionManager: EntityManager) => Promise, isolationOrErrorHandler?: | IsolationLevel - | ((error: unknown) => Promise), - maybeErrorHandlerOrDontFail?: (error: unknown) => Promise - ): Promise { + | ((error: TError) => Promise), + maybeErrorHandlerOrDontFail?: ( + error: TError + ) => Promise + ): Promise { let errorHandler = maybeErrorHandlerOrDontFail let isolation: | IsolationLevel - | ((error: unknown) => Promise) + | ((error: TError) => Promise) | undefined | null = isolationOrErrorHandler let dontFail = false @@ -238,7 +233,7 @@ class BaseService< } if (this.transactionManager_) { - const doWork = async (m: EntityManager): Promise => { + const doWork = async (m: EntityManager): Promise => { this.manager_ = m this.transactionManager_ = m try { @@ -256,10 +251,10 @@ class BaseService< } } - return doWork(this.transactionManager_) + return await doWork(this.transactionManager_) } else { const temp = this.manager_ - const doWork = async (m: EntityManager): Promise => { + const doWork = async (m: EntityManager): Promise => { this.manager_ = m this.transactionManager_ = m try { @@ -284,8 +279,9 @@ class BaseService< return result } catch (error) { if (this.shouldRetryTransaction(error)) { - return this.manager_.transaction(isolation as IsolationLevel, (m) => - doWork(m) + return this.manager_.transaction( + isolation as IsolationLevel, + (m): Promise => doWork(m) ) } else { if (errorHandler) { @@ -302,7 +298,7 @@ class BaseService< if (errorHandler) { const result = await errorHandler(error) if (dontFail) { - return result + return result as TResult } } @@ -338,5 +334,4 @@ class BaseService< ...newData, } } -} -export default BaseService +} \ No newline at end of file From 1499bc52e36616ca9d67fd55bf5965478dff390f Mon Sep 17 00:00:00 2001 From: adrien2p Date: Thu, 14 Apr 2022 18:55:27 +0200 Subject: [PATCH 02/92] feat(medusa): Add writable type --- packages/medusa/src/types/common.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index 94f06c01e1..9837a0de1e 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -7,6 +7,8 @@ export type PartialPick = { [P in K]?: T[P] } +export type Writable = { -readonly [key in keyof T]: T[key] } + export type TotalField = | "shipping_total" | "discount_total" From 99146b74037b89c6893c97e77e43a1aaf1c2a3d4 Mon Sep 17 00:00:00 2001 From: adrien2p Date: Sun, 17 Apr 2022 20:52:48 +0200 Subject: [PATCH 03/92] feat(medusa): Export transaction related methods to the transactionBaseService --- .../medusa/src/interfaces/base-service.ts | 137 +--------------- .../interfaces/transaction-base-service.ts | 149 ++++++++++++++++++ 2 files changed, 150 insertions(+), 136 deletions(-) create mode 100644 packages/medusa/src/interfaces/transaction-base-service.ts diff --git a/packages/medusa/src/interfaces/base-service.ts b/packages/medusa/src/interfaces/base-service.ts index d606da89e7..b87c15dd32 100644 --- a/packages/medusa/src/interfaces/base-service.ts +++ b/packages/medusa/src/interfaces/base-service.ts @@ -1,6 +1,5 @@ import { MedusaError } from "medusa-core-utils" import { EntityManager, FindOperator, In, Raw } from "typeorm" -import { IsolationLevel } from "typeorm/driver/types/IsolationLevel" import { FindConfig, Writable } from "../types/common" type Selector = { [key in keyof TEntity]?: unknown } @@ -24,24 +23,6 @@ export class BaseService< this.container_ = container } - withTransaction(transactionManager?: EntityManager): this | TChild { - if (!transactionManager) { - return this - } - - const cloned = new (this.constructor)( - { - ...this.container_, - manager: transactionManager, - }, - this.configModule - ) - - cloned.transactionManager_ = transactionManager - - return cloned as TChild - } - /** * Used to build TypeORM queries. * @param selector The selector @@ -191,122 +172,6 @@ export class BaseService< return rawId } - shouldRetryTransaction( - err: { code: string } | Record - ): boolean { - if (!(err as { code: string })?.code) { - return false - } - const code = (err as { code: string })?.code - return code === "40001" || code === "40P01" - } - - /** - * Wraps some work within a transactional block. If the service already has - * a transaction manager attached this will be reused, otherwise a new - * transaction manager is created. - * @param work - the transactional work to be done - * @param isolationOrErrorHandler - the isolation level to be used for the work. - * @param maybeErrorHandlerOrDontFail Potential error handler - * @return the result of the transactional work - */ - async atomicPhase_( - work: (transactionManager: EntityManager) => Promise, - isolationOrErrorHandler?: - | IsolationLevel - | ((error: TError) => Promise), - maybeErrorHandlerOrDontFail?: ( - error: TError - ) => Promise - ): Promise { - let errorHandler = maybeErrorHandlerOrDontFail - let isolation: - | IsolationLevel - | ((error: TError) => Promise) - | undefined - | null = isolationOrErrorHandler - let dontFail = false - if (typeof isolationOrErrorHandler === "function") { - isolation = null - errorHandler = isolationOrErrorHandler - dontFail = !!maybeErrorHandlerOrDontFail - } - - if (this.transactionManager_) { - const doWork = async (m: EntityManager): Promise => { - this.manager_ = m - this.transactionManager_ = m - try { - return await work(m) - } catch (error) { - if (errorHandler) { - const queryRunner = this.transactionManager_.queryRunner - if (queryRunner && queryRunner.isTransactionActive) { - await queryRunner.rollbackTransaction() - } - - await errorHandler(error) - } - throw error - } - } - - return await doWork(this.transactionManager_) - } else { - const temp = this.manager_ - const doWork = async (m: EntityManager): Promise => { - this.manager_ = m - this.transactionManager_ = m - try { - const result = await work(m) - this.manager_ = temp - this.transactionManager_ = undefined - return result - } catch (error) { - this.manager_ = temp - this.transactionManager_ = undefined - throw error - } - } - - if (isolation) { - let result - try { - result = await this.manager_.transaction( - isolation as IsolationLevel, - (m) => doWork(m) - ) - return result - } catch (error) { - if (this.shouldRetryTransaction(error)) { - return this.manager_.transaction( - isolation as IsolationLevel, - (m): Promise => doWork(m) - ) - } else { - if (errorHandler) { - await errorHandler(error) - } - throw error - } - } - } - - try { - return await this.manager_.transaction((m) => doWork(m)) - } catch (error) { - if (errorHandler) { - const result = await errorHandler(error) - if (dontFail) { - return result as TResult - } - } - - throw error - } - } - } - /** * Dedicated method to set metadata. * @param obj - the entity to apply metadata to. @@ -334,4 +199,4 @@ export class BaseService< ...newData, } } -} \ No newline at end of file +} diff --git a/packages/medusa/src/interfaces/transaction-base-service.ts b/packages/medusa/src/interfaces/transaction-base-service.ts new file mode 100644 index 0000000000..cbddd4b6f5 --- /dev/null +++ b/packages/medusa/src/interfaces/transaction-base-service.ts @@ -0,0 +1,149 @@ +import { EntityManager } from "typeorm" +import { IsolationLevel } from "typeorm/driver/types/IsolationLevel" + +export abstract class TransactionBaseService< + TChild extends TransactionBaseService, + TContainer = unknown +> { + protected abstract manager_: EntityManager + protected abstract transactionManager_: EntityManager | undefined + + protected constructor( + protected readonly container: TContainer, + protected readonly configModule?: Record + ) {} + + withTransaction(transactionManager?: EntityManager): this | TChild { + if (!transactionManager) { + return this + } + + const cloned = new (this.constructor)( + { + ...this.container, + manager: transactionManager, + }, + this.configModule + ) + + cloned.transactionManager_ = transactionManager + + return cloned as TChild + } + + shouldRetryTransaction( + err: { code: string } | Record + ): boolean { + if (!(err as { code: string })?.code) { + return false + } + const code = (err as { code: string })?.code + return code === "40001" || code === "40P01" + } + + /** + * Wraps some work within a transactional block. If the service already has + * a transaction manager attached this will be reused, otherwise a new + * transaction manager is created. + * @param work - the transactional work to be done + * @param isolationOrErrorHandler - the isolation level to be used for the work. + * @param maybeErrorHandlerOrDontFail Potential error handler + * @return the result of the transactional work + */ + async atomicPhase_( + work: (transactionManager: EntityManager) => Promise, + isolationOrErrorHandler?: + | IsolationLevel + | ((error: TError) => Promise), + maybeErrorHandlerOrDontFail?: ( + error: TError + ) => Promise + ): Promise { + let errorHandler = maybeErrorHandlerOrDontFail + let isolation: + | IsolationLevel + | ((error: TError) => Promise) + | undefined + | null = isolationOrErrorHandler + let dontFail = false + if (typeof isolationOrErrorHandler === "function") { + isolation = null + errorHandler = isolationOrErrorHandler + dontFail = !!maybeErrorHandlerOrDontFail + } + + if (this.transactionManager_) { + const doWork = async (m: EntityManager): Promise => { + this.manager_ = m + this.transactionManager_ = m + try { + return await work(m) + } catch (error) { + if (errorHandler) { + const queryRunner = this.transactionManager_.queryRunner + if (queryRunner && queryRunner.isTransactionActive) { + await queryRunner.rollbackTransaction() + } + + await errorHandler(error) + } + throw error + } + } + + return await doWork(this.transactionManager_) + } else { + const temp = this.manager_ + const doWork = async (m: EntityManager): Promise => { + this.manager_ = m + this.transactionManager_ = m + try { + const result = await work(m) + this.manager_ = temp + this.transactionManager_ = undefined + return result + } catch (error) { + this.manager_ = temp + this.transactionManager_ = undefined + throw error + } + } + + if (isolation && this.manager_) { + let result + try { + result = await this.manager_.transaction( + isolation as IsolationLevel, + (m) => doWork(m) + ) + return result + } catch (error) { + if (this.shouldRetryTransaction(error)) { + return this.manager_.transaction( + isolation as IsolationLevel, + (m): Promise => doWork(m) + ) + } else { + if (errorHandler) { + await errorHandler(error) + } + throw error + } + } + } + + try { + return await this.manager_.transaction((m) => doWork(m)) + } catch (error) { + if (errorHandler) { + const result = await errorHandler(error) + if (dontFail) { + return result as TResult + } + } + + throw error + } + } + } +} From e7e715ac177bd0b883ffa0d72a208580012bfbab Mon Sep 17 00:00:00 2001 From: adrien2p Date: Mon, 18 Apr 2022 15:45:33 +0200 Subject: [PATCH 04/92] feat(medusa): Split base service to its related TransactionBaseService and utilities methods when required --- .../interfaces/__tests__/base-service.spec.ts | 37 +--- .../medusa/src/interfaces/base-service.ts | 202 ------------------ packages/medusa/src/interfaces/index.ts | 2 +- .../src/utils/__tests__/build-query.spec.ts | 28 +++ packages/medusa/src/utils/build-query.ts | 112 ++++++++++ packages/medusa/src/utils/set-metadata.ts | 29 +++ packages/medusa/src/utils/validate-id.ts | 41 ++++ 7 files changed, 218 insertions(+), 233 deletions(-) delete mode 100644 packages/medusa/src/interfaces/base-service.ts create mode 100644 packages/medusa/src/utils/__tests__/build-query.spec.ts create mode 100644 packages/medusa/src/utils/build-query.ts create mode 100644 packages/medusa/src/utils/set-metadata.ts create mode 100644 packages/medusa/src/utils/validate-id.ts diff --git a/packages/medusa/src/interfaces/__tests__/base-service.spec.ts b/packages/medusa/src/interfaces/__tests__/base-service.spec.ts index 9fa804e4f1..65c5b62608 100644 --- a/packages/medusa/src/interfaces/__tests__/base-service.spec.ts +++ b/packages/medusa/src/interfaces/__tests__/base-service.spec.ts @@ -1,10 +1,13 @@ -import { BaseService } from "../base-service" -import { In, Not } from "typeorm" +import { EntityManager } from "typeorm" import { MockManager } from "medusa-test-utils" +import { TransactionBaseService } from "../transaction-base-service" -describe("BaseService", () => { +describe("TransactionBaseService", () => { it("should cloned the child class withTransaction", () => { - class Child extends BaseService { + class Child extends TransactionBaseService { + protected manager_!: EntityManager + protected transactionManager_!: EntityManager + constructor(protected readonly container) { super(container); this.container = container @@ -32,30 +35,4 @@ describe("BaseService", () => { expect(child2.getTransactionManager()).toBeTruthy() expect((child2.getTransactionManager() as any)?.testProp).toBe('testProp') }) - - describe("buildQuery_", () => { - const baseService = new BaseService({}, {}) - - it("successfully creates query", () => { - const q = baseService.buildQuery_( - { - id: "1234", - test1: ["123", "12", "1"], - test2: Not("this"), - }, - { - relations: ["1234"], - } - ) - - expect(q).toEqual({ - where: { - id: "1234", - test1: In(["123", "12", "1"]), - test2: Not("this"), - }, - relations: ["1234"], - }) - }) - }) }) \ No newline at end of file diff --git a/packages/medusa/src/interfaces/base-service.ts b/packages/medusa/src/interfaces/base-service.ts deleted file mode 100644 index b87c15dd32..0000000000 --- a/packages/medusa/src/interfaces/base-service.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { MedusaError } from "medusa-core-utils" -import { EntityManager, FindOperator, In, Raw } from "typeorm" -import { FindConfig, Writable } from "../types/common" - -type Selector = { [key in keyof TEntity]?: unknown } - -/** - * Common functionality for Services - * @interface - */ -export class BaseService< - TChild extends BaseService, - TContainer = unknown -> { - protected transactionManager_: EntityManager | undefined - protected manager_: EntityManager - private readonly container_: TContainer - - constructor( - container: TContainer, - protected readonly configModule?: Record - ) { - this.container_ = container - } - - /** - * Used to build TypeORM queries. - * @param selector The selector - * @param config The config - * @return The QueryBuilderConfig - */ - buildQuery_( - selector: Selector, - config: FindConfig = {} - ): FindConfig & { - where: Partial> - withDeleted?: boolean - } { - const build = ( - obj: Record - ): Partial> => { - return Object.entries(obj).reduce((acc, [key, value]: any) => { - // Undefined values indicate that they have no significance to the query. - // If the query is looking for rows where a column is not set it should use null instead of undefined - if (typeof value === "undefined") { - return acc - } - - const subquery: { - operator: "<" | ">" | "<=" | ">=" - value: unknown - }[] = [] - - switch (true) { - case value instanceof FindOperator: - acc[key] = value - break - case Array.isArray(value): - acc[key] = In([...(value as unknown[])]) - break - case value !== null && typeof value === "object": - Object.entries(value).map(([modifier, val]) => { - switch (modifier) { - case "lt": - subquery.push({ operator: "<", value: val }) - break - case "gt": - subquery.push({ operator: ">", value: val }) - break - case "lte": - subquery.push({ operator: "<=", value: val }) - break - case "gte": - subquery.push({ operator: ">=", value: val }) - break - default: - acc[key] = value - break - } - }) - - if (subquery.length) { - acc[key] = Raw( - (a) => - subquery - .map((s, index) => `${a} ${s.operator} :${index}`) - .join(" AND "), - subquery.map((s) => s.value) - ) - } - break - default: - acc[key] = value - break - } - - return acc - }, {} as Partial>) - } - - const query: FindConfig & { - where: Partial> - withDeleted?: boolean - } = { - where: build(selector), - } - - if ("deleted_at" in selector) { - query.withDeleted = true - } - - if ("skip" in config) { - query.skip = config.skip - } - - if ("take" in config) { - query.take = config.take - } - - if ("relations" in config) { - query.relations = config.relations - } - - if ("select" in config) { - query.select = config.select - } - - if ("order" in config) { - query.order = config.order - } - - return query - } - - /** - * Confirms whether a given raw id is valid. Fails if the provided - * id is null or undefined. The validate function takes an optional config - * param, to support checking id prefix and length. - * @param rawId - the id to validate. - * @param config - optional config - * @returns the rawId given that nothing failed - */ - validateId_( - rawId: string, - config: { prefix?: string; length?: number } = {} - ): string { - const { prefix, length } = config - if (!rawId) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Failed to validate id: ${rawId}` - ) - } - - if (prefix || length) { - const [pre, rand] = rawId.split("_") - if (prefix && pre !== prefix) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `The provided id: ${rawId} does not adhere to prefix constraint: ${prefix}` - ) - } - - if (length && length !== rand.length) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `The provided id: ${rawId} does not adhere to length constraint: ${length}` - ) - } - } - - return rawId - } - - /** - * Dedicated method to set metadata. - * @param obj - the entity to apply metadata to. - * @param metadata - the metadata to set - * @return resolves to the updated result. - */ - setMetadata_( - obj: { metadata: Record }, - metadata: Record - ): Record { - const existing = obj.metadata || {} - const newData = {} - for (const [key, value] of Object.entries(metadata)) { - if (typeof key !== "string") { - throw new MedusaError( - MedusaError.Types.INVALID_ARGUMENT, - "Key type is invalid. Metadata keys must be strings" - ) - } - newData[key] = value - } - - return { - ...existing, - ...newData, - } - } -} diff --git a/packages/medusa/src/interfaces/index.ts b/packages/medusa/src/interfaces/index.ts index 7552d3c149..9472f26f30 100644 --- a/packages/medusa/src/interfaces/index.ts +++ b/packages/medusa/src/interfaces/index.ts @@ -1,4 +1,4 @@ export * from "./tax-calculation-strategy" export * from "./cart-completion-strategy" export * from "./tax-service" -export * from "./base-service" +export * from "./transaction-base-service" diff --git a/packages/medusa/src/utils/__tests__/build-query.spec.ts b/packages/medusa/src/utils/__tests__/build-query.spec.ts new file mode 100644 index 0000000000..f9a4819390 --- /dev/null +++ b/packages/medusa/src/utils/__tests__/build-query.spec.ts @@ -0,0 +1,28 @@ +import { In, Not } from "typeorm" +import { buildQuery } from "../build-query" + +describe('buildQuery', () => { + describe("buildQuery_", () => { + it("successfully creates query", () => { + const q = buildQuery( + { + id: "1234", + test1: ["123", "12", "1"], + test2: Not("this"), + }, + { + relations: ["1234"], + } + ) + + expect(q).toEqual({ + where: { + id: "1234", + test1: In(["123", "12", "1"]), + test2: Not("this"), + }, + relations: ["1234"], + }) + }) + }) +}) \ No newline at end of file diff --git a/packages/medusa/src/utils/build-query.ts b/packages/medusa/src/utils/build-query.ts new file mode 100644 index 0000000000..103af54379 --- /dev/null +++ b/packages/medusa/src/utils/build-query.ts @@ -0,0 +1,112 @@ +import { FindConfig, Writable } from "../types/common" +import { FindOperator, In, Raw } from "typeorm" + +type Selector = { [key in keyof TEntity]?: unknown } +/** +* Used to build TypeORM queries. +* @param selector The selector +* @param config The config +* @return The QueryBuilderConfig +*/ +export function buildQuery( + selector: Selector, + config: FindConfig = {} +): FindConfig & { + where: Partial> + withDeleted?: boolean +} { + const build = ( + obj: Record + ): Partial> => { + return Object.entries(obj).reduce((acc, [key, value]: any) => { + // Undefined values indicate that they have no significance to the query. + // If the query is looking for rows where a column is not set it should use null instead of undefined + if (typeof value === "undefined") { + return acc + } + + const subquery: { + operator: "<" | ">" | "<=" | ">=" + value: unknown + }[] = [] + + switch (true) { + case value instanceof FindOperator: + acc[key] = value + break + case Array.isArray(value): + acc[key] = In([...(value as unknown[])]) + break + case value !== null && typeof value === "object": + Object.entries(value).map(([modifier, val]) => { + switch (modifier) { + case "lt": + subquery.push({ operator: "<", value: val }) + break + case "gt": + subquery.push({ operator: ">", value: val }) + break + case "lte": + subquery.push({ operator: "<=", value: val }) + break + case "gte": + subquery.push({ operator: ">=", value: val }) + break + default: + acc[key] = value + break + } + }) + + if (subquery.length) { + acc[key] = Raw( + (a) => + subquery + .map((s, index) => `${a} ${s.operator} :${index}`) + .join(" AND "), + subquery.map((s) => s.value) + ) + } + break + default: + acc[key] = value + break + } + + return acc + }, {} as Partial>) + } + + const query: FindConfig & { + where: Partial> + withDeleted?: boolean + } = { + where: build(selector), + } + + if ("deleted_at" in selector) { + query.withDeleted = true + } + + if ("skip" in config) { + query.skip = config.skip + } + + if ("take" in config) { + query.take = config.take + } + + if ("relations" in config) { + query.relations = config.relations + } + + if ("select" in config) { + query.select = config.select + } + + if ("order" in config) { + query.order = config.order + } + + return query +} \ No newline at end of file diff --git a/packages/medusa/src/utils/set-metadata.ts b/packages/medusa/src/utils/set-metadata.ts new file mode 100644 index 0000000000..38399e1dbb --- /dev/null +++ b/packages/medusa/src/utils/set-metadata.ts @@ -0,0 +1,29 @@ +import { MedusaError } from "medusa-core-utils/dist" + +/** +* Dedicated method to set metadata. +* @param obj - the entity to apply metadata to. +* @param metadata - the metadata to set +* @return resolves to the updated result. +*/ +export function setMetadata_( + obj: { metadata: Record }, + metadata: Record +): Record { + const existing = obj.metadata || {} + const newData = {} + for (const [key, value] of Object.entries(metadata)) { + if (typeof key !== "string") { + throw new MedusaError( + MedusaError.Types.INVALID_ARGUMENT, + "Key type is invalid. Metadata keys must be strings" + ) + } + newData[key] = value + } + + return { + ...existing, + ...newData, + } +} \ No newline at end of file diff --git a/packages/medusa/src/utils/validate-id.ts b/packages/medusa/src/utils/validate-id.ts new file mode 100644 index 0000000000..2c616fad48 --- /dev/null +++ b/packages/medusa/src/utils/validate-id.ts @@ -0,0 +1,41 @@ +/** +* Confirms whether a given raw id is valid. Fails if the provided +* id is null or undefined. The validate function takes an optional config +* param, to support checking id prefix and length. +* @param rawId - the id to validate. +* @param config - optional config +* @returns the rawId given that nothing failed +*/ +import { MedusaError } from "medusa-core-utils/dist" + +export function validateId_( + rawId: string, + config: { prefix?: string; length?: number } = {} +): string { + const { prefix, length } = config + if (!rawId) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Failed to validate id: ${rawId}` + ) + } + + if (prefix || length) { + const [pre, rand] = rawId.split("_") + if (prefix && pre !== prefix) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The provided id: ${rawId} does not adhere to prefix constraint: ${prefix}` + ) + } + + if (length && length !== rand.length) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The provided id: ${rawId} does not adhere to length constraint: ${length}` + ) + } + } + + return rawId +} \ No newline at end of file From b90291b18d438e3bb7f5239f4470eec7b3c5ff03 Mon Sep 17 00:00:00 2001 From: adrien2p Date: Mon, 18 Apr 2022 16:23:11 +0200 Subject: [PATCH 05/92] feat(medusa): Improve buildQuery as well as refactor the cart service as an example --- packages/medusa/src/services/cart.ts | 127 +++++++++++----------- packages/medusa/src/types/cart.ts | 2 +- packages/medusa/src/utils/build-query.ts | 19 +++- packages/medusa/src/utils/index.ts | 3 + packages/medusa/src/utils/set-metadata.ts | 2 +- packages/medusa/src/utils/validate-id.ts | 2 +- 6 files changed, 86 insertions(+), 69 deletions(-) create mode 100644 packages/medusa/src/utils/index.ts diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index 6e5d0c4284..c7baf16699 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -1,6 +1,5 @@ import _ from "lodash" import { MedusaError, Validator } from "medusa-core-utils" -import { BaseService } from "medusa-interfaces" import { DeepPartial, EntityManager, In } from "typeorm" import { IPriceSelectionStrategy } from "../interfaces/price-selection-strategy" import { Address } from "../models/address" @@ -37,6 +36,8 @@ import InventoryService from "./inventory" import CustomShippingOptionService from "./custom-shipping-option" import LineItemAdjustmentService from "./line-item-adjustment" import { LineItemRepository } from "../repositories/line-item" +import { TransactionBaseService } from "../interfaces" +import { buildQuery, setMetadata, validateId } from "../utils" type InjectedDependencies = { manager: EntityManager @@ -70,14 +71,16 @@ type TotalsConfig = { /* Provides layer to manipulate carts. * @implements BaseService */ -class CartService extends BaseService { - static Events = { +class CartService extends TransactionBaseService { + static readonly Events = { CUSTOMER_UPDATED: "cart.customer_updated", CREATED: "cart.created", UPDATED: "cart.updated", } - protected readonly manager_: EntityManager + protected manager_: EntityManager + protected transactionManager_: EntityManager | undefined + protected readonly shippingMethodRepository_: typeof ShippingMethodRepository protected readonly cartRepository_: typeof CartRepository protected readonly addressRepository_: typeof AddressRepository @@ -124,7 +127,30 @@ class CartService extends BaseService { lineItemAdjustmentService, priceSelectionStrategy, }: InjectedDependencies) { - super() + super({ + manager, + cartRepository, + shippingMethodRepository, + lineItemRepository, + eventBusService, + paymentProviderService, + productService, + productVariantService, + taxProviderService, + regionService, + lineItemService, + shippingOptionService, + customerService, + discountService, + giftCardService, + totalsService, + addressRepository, + paymentSessionRepository, + inventoryService, + customShippingOptionService, + lineItemAdjustmentService, + priceSelectionStrategy, + }) this.manager_ = manager this.shippingMethodRepository_ = shippingMethodRepository @@ -150,41 +176,6 @@ class CartService extends BaseService { this.priceSelectionStrategy_ = priceSelectionStrategy } - withTransaction(transactionManager: EntityManager): CartService { - if (!transactionManager) { - return this - } - - const cloned = new CartService({ - manager: transactionManager, - taxProviderService: this.taxProviderService_, - cartRepository: this.cartRepository_, - lineItemRepository: this.lineItemRepository_, - eventBusService: this.eventBus_, - paymentProviderService: this.paymentProviderService_, - paymentSessionRepository: this.paymentSessionRepository_, - shippingMethodRepository: this.shippingMethodRepository_, - productService: this.productService_, - productVariantService: this.productVariantService_, - regionService: this.regionService_, - lineItemService: this.lineItemService_, - shippingOptionService: this.shippingOptionService_, - customerService: this.customerService_, - discountService: this.discountService_, - totalsService: this.totalsService_, - addressRepository: this.addressRepository_, - giftCardService: this.giftCardService_, - inventoryService: this.inventoryService_, - customShippingOptionService: this.customShippingOptionService_, - lineItemAdjustmentService: this.lineItemAdjustmentService_, - priceSelectionStrategy: this.priceSelectionStrategy_, - }) - - cloned.transactionManager_ = transactionManager - - return cloned - } - protected transformQueryForTotals_( config: FindConfig ): FindConfig & { totalsToSelect: TotalField[] } { @@ -292,7 +283,7 @@ class CartService extends BaseService { this.cartRepository_ ) - const query = this.buildQuery_(selector, config) + const query = buildQuery(selector, config) return await cartRepo.find(query) } ) @@ -315,12 +306,12 @@ class CartService extends BaseService { const cartRepo = transactionManager.getCustomRepository( this.cartRepository_ ) - const validatedId = this.validateId_(cartId) + const validatedId = validateId(cartId) const { select, relations, totalsToSelect } = this.transformQueryForTotals_(options) - const query = this.buildQuery_( + const query = buildQuery( { id: validatedId }, { ...options, select, relations } ) @@ -714,16 +705,20 @@ class CartService extends BaseService { * @param shouldAdd - flag to indicate, if we should add or remove * @return void */ - async adjustFreeShipping_(cart: Cart, shouldAdd: boolean): Promise { + protected async adjustFreeShipping_( + cart: Cart, + shouldAdd: boolean + ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + if (cart.shipping_methods?.length) { - const shippingMethodRepository = - this.transactionManager_.getCustomRepository( - this.shippingMethodRepository_ - ) + const shippingMethodRepository = transactionManager.getCustomRepository( + this.shippingMethodRepository_ + ) // if any free shipping discounts, we ensure to update shipping method amount if (shouldAdd) { - return shippingMethodRepository.update( + await shippingMethodRepository.update( { id: In( cart.shipping_methods.map((shippingMethod) => shippingMethod.id) @@ -855,8 +850,8 @@ class CartService extends BaseService { ) } - if ("metadata" in data) { - cart.metadata = this.setMetadata_(cart, data.metadata) + if (data?.metadata) { + cart.metadata = setMetadata(cart, data.metadata) } if ("context" in data) { @@ -1352,7 +1347,7 @@ class CartService extends BaseService { * @param cartOrCartId - the id of the cart to set payment session for * @return the result of the update operation. */ - async setPaymentSessions(cartOrCartId: Cart | string): Promise { + async setPaymentSessions(cartOrCartId: Cart | string): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const psRepo = transactionManager.getCustomRepository( @@ -1679,6 +1674,8 @@ class CartService extends BaseService { regionId?: string, customer_id?: string ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + // If the cart contains items, we update the price of the items // to match the updated region or customer id (keeping the old // value if it exists) @@ -1693,7 +1690,7 @@ class CartService extends BaseService { await Promise.all( cart.items.map(async (item) => { const availablePrice = await this.priceSelectionStrategy_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .calculateVariantPrice(item.variant_id, { region_id: region.id, currency_code: region.currency_code, @@ -1708,14 +1705,14 @@ class CartService extends BaseService { availablePrice.calculatedPrice !== null ) { return this.lineItemService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .update(item.id, { has_shipping: false, unit_price: availablePrice.calculatedPrice, }) } else { await this.lineItemService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .delete(item.id) return } @@ -1737,6 +1734,8 @@ class CartService extends BaseService { regionId: string, countryCode: string | null ): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + if (cart.completed_at || cart.payment_authorized_at) { throw new MedusaError( MedusaError.Types.NOT_ALLOWED, @@ -1750,7 +1749,7 @@ class CartService extends BaseService { .retrieve(regionId, { relations: ["countries"], }) - const addrRepo = this.transactionManager_.getCustomRepository( + const addrRepo = transactionManager.getCustomRepository( this.addressRepository_ ) cart.region = region @@ -1828,7 +1827,7 @@ class CartService extends BaseService { // new shipping method if (cart.shipping_methods && cart.shipping_methods.length) { await this.shippingOptionService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .deleteShippingMethods(cart.shipping_methods) } @@ -1841,7 +1840,7 @@ class CartService extends BaseService { cart.gift_cards = [] if (cart.payment_sessions && cart.payment_sessions.length) { - const paymentSessionRepo = this.transactionManager_.getCustomRepository( + const paymentSessionRepo = transactionManager.getCustomRepository( this.paymentSessionRepository_ ) await paymentSessionRepo.delete({ @@ -1859,7 +1858,7 @@ class CartService extends BaseService { * @param cartId - the id of the cart to delete * @return the deleted cart or undefined if the cart was not found. */ - async delete(cartId: string): Promise { + async delete(cartId: string): Promise { return await this.atomicPhase_( async (transactionManager: EntityManager) => { const cart = await this.retrieve(cartId, { @@ -1913,7 +1912,7 @@ class CartService extends BaseService { this.cartRepository_ ) - const validatedId = this.validateId_(cartId) + const validatedId = validateId(cartId) if (typeof key !== "string") { throw new MedusaError( MedusaError.Types.INVALID_ARGUMENT, @@ -1972,20 +1971,22 @@ class CartService extends BaseService { } protected async refreshAdjustments_(cart: Cart): Promise { + const transactionManager = this.transactionManager_ ?? this.manager_ + const nonReturnLineIDs = cart.items .filter((item) => !item.is_return) .map((i) => i.id) // delete all old non return line item adjustments await this.lineItemAdjustmentService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .delete({ item_id: nonReturnLineIDs, }) // potentially create/update line item adjustments await this.lineItemAdjustmentService_ - .withTransaction(this.transactionManager_) + .withTransaction(transactionManager) .createAdjustments(cart) } @@ -2001,7 +2002,7 @@ class CartService extends BaseService { const cartRepo = transactionManager.getCustomRepository( this.cartRepository_ ) - const validatedId = this.validateId_(cartId) + const validatedId = validateId(cartId) if (typeof key !== "string") { throw new MedusaError( diff --git a/packages/medusa/src/types/cart.ts b/packages/medusa/src/types/cart.ts index 249c0c9af0..dfba20b82f 100644 --- a/packages/medusa/src/types/cart.ts +++ b/packages/medusa/src/types/cart.ts @@ -71,5 +71,5 @@ export type CartUpdateProps = { discounts?: Discount[] customer_id?: string context?: object - metadata?: object + metadata?: Record } diff --git a/packages/medusa/src/utils/build-query.ts b/packages/medusa/src/utils/build-query.ts index 103af54379..f116404729 100644 --- a/packages/medusa/src/utils/build-query.ts +++ b/packages/medusa/src/utils/build-query.ts @@ -1,7 +1,20 @@ -import { FindConfig, Writable } from "../types/common" +import { + DateComparisonOperator, + FindConfig, + NumericalComparisonOperator, + StringComparisonOperator, + Writable, +} from "../types/common" import { FindOperator, In, Raw } from "typeorm" -type Selector = { [key in keyof TEntity]?: unknown } +type Selector = { + [key in keyof TEntity]?: TEntity[key] + | TEntity[key][] + | DateComparisonOperator + | StringComparisonOperator + | NumericalComparisonOperator +} + /** * Used to build TypeORM queries. * @param selector The selector @@ -16,7 +29,7 @@ export function buildQuery( withDeleted?: boolean } { const build = ( - obj: Record + obj: Selector ): Partial> => { return Object.entries(obj).reduce((acc, [key, value]: any) => { // Undefined values indicate that they have no significance to the query. diff --git a/packages/medusa/src/utils/index.ts b/packages/medusa/src/utils/index.ts new file mode 100644 index 0000000000..5d7ec9889f --- /dev/null +++ b/packages/medusa/src/utils/index.ts @@ -0,0 +1,3 @@ +export * from './build-query' +export * from './set-metadata' +export * from './validate-id' \ No newline at end of file diff --git a/packages/medusa/src/utils/set-metadata.ts b/packages/medusa/src/utils/set-metadata.ts index 38399e1dbb..d27cddc5ed 100644 --- a/packages/medusa/src/utils/set-metadata.ts +++ b/packages/medusa/src/utils/set-metadata.ts @@ -6,7 +6,7 @@ import { MedusaError } from "medusa-core-utils/dist" * @param metadata - the metadata to set * @return resolves to the updated result. */ -export function setMetadata_( +export function setMetadata( obj: { metadata: Record }, metadata: Record ): Record { diff --git a/packages/medusa/src/utils/validate-id.ts b/packages/medusa/src/utils/validate-id.ts index 2c616fad48..da6102e69a 100644 --- a/packages/medusa/src/utils/validate-id.ts +++ b/packages/medusa/src/utils/validate-id.ts @@ -8,7 +8,7 @@ */ import { MedusaError } from "medusa-core-utils/dist" -export function validateId_( +export function validateId( rawId: string, config: { prefix?: string; length?: number } = {} ): string { From f14b3d7f286c37b55cd84cc5082dc4a881ed9079 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Fri, 29 Apr 2022 16:01:54 +0300 Subject: [PATCH 06/92] updated development --- .../0-set-up-your-development-environment.md | 126 ------ .../0-set-up-your-development-environment.mdx | 364 ++++++++++++++++++ docs/content/tutorial/development.module.css | 5 + www/docs/src/theme/Tabs/index.js | 4 +- 4 files changed, 371 insertions(+), 128 deletions(-) delete mode 100644 docs/content/tutorial/0-set-up-your-development-environment.md create mode 100644 docs/content/tutorial/0-set-up-your-development-environment.mdx create mode 100644 docs/content/tutorial/development.module.css diff --git a/docs/content/tutorial/0-set-up-your-development-environment.md b/docs/content/tutorial/0-set-up-your-development-environment.md deleted file mode 100644 index 9c692f23fe..0000000000 --- a/docs/content/tutorial/0-set-up-your-development-environment.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -title: Set up your development environment ---- - -# Set up your development environment - -## Introduction - -Welcome to Medusa - we are so excited to get you on board! - -This tutorial will walk you through the steps to take to set up your local development environment. You will familiarize yourself with some of the core parts that make Medusa work and learn how to configure your development environment. Furthermore you will be introduced to how the plugin architecture works and how to customize your commerce functionalities with custom logic and endpoints. - - - -## Background Knowledge and Prerequisites - -This tutorial aims to be as inclusive as possible so that most people with a bit of web development exeperience will be able to follow along and understand what is going on. In case you are completely new to programming and are just setting out to start working on your first project it will be helpful to familiarize yourself with a couple of the technologies that Medusa is build on - -- **JavaScript**: The programming language that Medusa is written in. It is the language that runs in your browser to create dynamic web applications and has over the past decade gained a lot of traction as a backend language. If you wish to customize or extend Medusa it is highly recommended that you learn how JavaScript works. You can learn more about JavaScript with the [Basic JavaScript course from freeCodeCamp.](https://www.freecodecamp.org/learn/javascript-algorithms-and-data-structures/#basic-javascript) -- **SQL & Postgresql**: Medusa uses the relational database PostgreSQL as the database layer where data is persisted. To understand how different entities relate to each other in Medusa it is helpful to have a good understanding of SQL. You can learn more about SQL and relational databases with the [SQL and Databases course from freeCodeCamp.](https://www.freecodecamp.org/news/sql-and-databases-full-course/) -- The **command line**: The command line is a text interface for your computer. It is used to run commands such as starting a program, performing a task or interfacing with the files on your computer. If you have never used the command line before you can check out [this tutorial](https://www.learnenough.com/command-line-tutorial) to get the basics in place. - -To get a further understanding of what powers Medusa you can lookup these concepts: - -- [**REST APIs**](https://en.wikipedia.org/wiki/Representational_state_transfer) -- [**Dependency Injection**](https://en.wikipedia.org/wiki/Dependency_injection) -- [**Idempotency Keys**](https://brandur.org/idempotency-keys) - -## Installations - -To get your development environment ready you need to install the following tools: - -- Node.js -- Git -- Postgresql -- Redis -- Medusa CLI - -### Node.js - -Node.js is an environment that can execute JavaScript code on outside of the browser making it possible to run on a server. Node.js is the environment that makes it possible for Medusa to run so you must install Node.js on your computer to start Medusa development. - -Node.js has a bundled package manager called npm. npm helps you install "packages" which are small pieces of code that you can leverage in your Node.js applications. Medusa's core is itself a package distributed via npm and so are all of the plugins that exist around the core. [You can install Node.js from here.](https://nodejs.org/en/) - -:::caution - -Medusa supports Node versions 14 and 16. You can check which Node version you have using the following command: - -```bash -node -v -``` - -::: - -If you prefer using something like homebrew you can also run: - -``` -brew install node -``` - -:::tip - -**Mac users**: Make sure that you have Xcode command line tools installed; if not run `xcode-select --install` - -::: - -### Git - -Git is a version control system that keeps track on files within a project and makes it possible to do things like going back in history if you have made mistakes or collaborate with teammates without overriding each other's work. Almost all developers use Git for version control. Medusa uses git behind the scenes when you create a new project so you'll have to install it on your computer to get started. - -If you are a Mac user you will already have Git installed as part of the Xcode command line tools, but for good measure check out installation of Git on different systems below: - -- [Install Git on macOS](https://www.atlassian.com/git/tutorials/install-git) -- [Install Git on Windows](https://www.atlassian.com/git/tutorials/install-git#windows) -- [Install Git on Linux](https://www.atlassian.com/git/tutorials/install-git#linux) - -### PostgreSQL - -PostgreSQL is an open-source relational database system with more than 30 years of active development. It is robust, reliable and ensures data integrity so there's no need to worry about those when you scale your project. Medusa uses PostgreSQL as its database and you will need to install it on your computer to get going. [Install PostgreSQL from here.](https://www.postgresql.org/download/). - -If you prefer to use homebrew you may install PostgreSQL by running: - -``` -brew install postgresql -brew services start postgresql -createdb -``` - -### Redis - -Redis is an open-source in memory data structure store which is used in Medusa to emit messages in the system and cache data. [Install Redis from here.](https://redis.io/download) - -If you prefer to use homebrew you may install Redis by running: - -``` -brew install redis -brew services start redis -``` - -### Medusa CLI - -The final installation to do to get started with Medusa is the Medusa CLI, which is an npm package you can install globally on your computer to get instant access to commands that help you manage and run your Medusa project. As the Medusa CLI is distributed as an npm package it is very easily installed by running: - -```bash npm2yarn -npm install @medusajs/medusa-cli -g -``` - -### Text editor - -If you don't already have a text editor of choice you should find one you like - here is a couple of candidates: - -- [Neovim](https://neovim.io/) (if you are super oldschool there's also plain [Vim](https://www.vim.org/)) -- [VS Code](https://code.visualstudio.com/) -- [Atom](https://atom.io/) - -It is not important which editor you use as long as you feel comfortable working with it. - - - -## Summary - -You now have all required software installed on your computer and have been introduced to a bit of our tech stack. In the next part of this tutorial we will be setting up a Medusa project for the first time and start making API requests. diff --git a/docs/content/tutorial/0-set-up-your-development-environment.mdx b/docs/content/tutorial/0-set-up-your-development-environment.mdx new file mode 100644 index 0000000000..6f67bf99a2 --- /dev/null +++ b/docs/content/tutorial/0-set-up-your-development-environment.mdx @@ -0,0 +1,364 @@ +import styles from './development.module.css'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Set up your development environment + +This document will guide you to set up your development environment to efficiently and properly use Medusa. + +## Prerequisite Background Knowledge + +### JavaScript + +Medusa is built with JavaScript. If you’re not familiar with JavaScript, it is the language that runs in your browser to create dynamic web applications and has over the past decade gained a lot of traction as a backend language. If you wish to customize or extend Medusa, it is highly recommended that you learn how JavaScript works. + +You can learn more about JavaScript with the [Basic JavaScript course from freeCodeCamp.](https://www.freecodecamp.org/learn/javascript-algorithms-and-data-structures/#basic-javascript) + +### Express + +Medusa uses [Express](https://expressjs.com), a Node.js web application framework, to create your ecommerce server. It facilitates creating REST APIs in Node.js. If you’re interested in customizing Medusa or understanding more about how it works, you should learn more about Express. + +You can learn more about Node.js and Express with the [Free 8-hour-long Node.js + Express course from freeCodeCamp](https://www.freecodecamp.org/news/free-8-hour-node-express-course/). + +### SQL + +SQL is a programming language used to interact with relational databases and store data in your ecommerce server. To understand how different entities relate to each other in Medusa it is helpful to have a good understanding of SQL. + +You can learn more about SQL and relational databases with the [SQL and Databases course from freeCodeCamp.](https://www.freecodecamp.org/news/sql-and-databases-full-course/) + +### Command Line Interface (CLI) + +To install and use Medusa, you’ll need to be familiar with CLI tools. If you’re not familiar with the command line, it is a text interface for your computer. It is used to run commands such as starting a program, performing a task, or interfacing with the files on your computer. + +If you have never used the command line before you can check out [this tutorial](https://www.learnenough.com/command-line-tutorial) to get the basics in place. + +### Additional Information + +To get a further understanding of what powers Medusa you can lookup these concepts: + +- [REST APIs](https://en.wikipedia.org/wiki/Representational_state_transfer) +- [Dependency Injection](https://en.wikipedia.org/wiki/Dependency_injection) +- [Idempotency Keys](https://brandur.org/idempotency-keys) + +## Installations[](https://docs.medusajs.com/tutorial/set-up-your-development-environment#installations) + +To get your development environment ready you need to install the following tools: + +- [Node.js](#nodejs) +- [Git](#git) +- [Postgresql](#postgresql) +- [Redis](#redis) +- [Medusa CLI](#medusa-cli) +- [Code Editor](#code-editor) + +### Node.js + +:::info + +Node.js is an environment that can execute JavaScript code outside of the browser, making it possible to run on a server. + +Node.js has a bundled package manager called NPM. NPM helps you install "packages" which are small pieces of code that you can leverage in your Node.js applications. Medusa's core is itself a package distributed via NPM and so are all of the plugins that exist around the core. + +::: + +Node.js is the environment that makes it possible for Medusa to run, so you must install Node.js on your computer to start Medusa development. + +:::caution + +Medusa supports versions 14 and 16 of Node.js. You can check your Node.js version using the following command: + +```bash +node -v +``` + +::: + + + + +You can install the executable directly from [the Node.js website](https://nodejs.org/en/#home-downloadhead). + +For other approaches, you can check out [Node.js’s guide](https://nodejs.org/en/download/package-manager/#windows-1). + + + + +You can use the following commands to install Node.js on Ubuntu: + +```bash +#Ubuntu +sudo apt update +sudo apt install nodejs +``` + +For other Linux distributions, you can check out [Node.js’s guide](https://nodejs.org/en/download/package-manager/). + + + + +You can use the following commands to install Node.js on macOS: + + + + +```bash +brew install node +``` + + + + +```bash +curl "https://nodejs.org/dist/latest/node-${VERSION:-$(wget -qO- https://nodejs.org/dist/latest/ | sed -nE 's|.*>node-(.*)\.pkg.*|\1|p')}.pkg" > "$HOME/Downloads/node-latest.pkg" && sudo installer -store -pkg "$HOME/Downloads/node-latest.pkg" -target "/" +``` + + + + +For other approaches, you can check out [Node.js’s guide](https://nodejs.org/en/download/package-manager/#macos). + +:::tip + +Make sure that you have Xcode command line tools installed; if not, run the following command to install it: `xcode-select --install` + +::: + + + +### Git + +:::info + +Git is a version control system that keeps track of files within a project and makes it possible to do things like going back in history if you have made mistakes or collaborate with teammates without overriding each other's work. + +::: + +Medusa uses Git behind the scenes when you create a new project so you'll have to install it on your computer to get started. + + + + +To install Git on Windows, you need to [download the installable package](https://git-scm.com/download/win). + + + + +For Debian/Ubuntu, you can use the following command: + +```bash +apt-get install git +``` + +As for other Linux distributions, please check [git’s guide](https://git-scm.com/download/linux). + + + + +You should already have Git installed as part of the Xcode command-line tools. + +However, if for any reason you need to install it manually, you can install it with Homebrew: + +```bash +brew install git +``` + +You can also check out [git’s guide](https://git-scm.com/download/mac) for more installation options. + + + + +### PostgreSQL + +:::info + +PostgreSQL is an open-source relational database system with more than 30 years of active development. It is robust, reliable, and ensures data integrity so there's no need to worry about those when you scale your project. + +::: + +Although you can use an SQLite database with Medusa which would require no necessary database installations, it is recommended to use a PostgreSQL database for your server. + + + + +You can [download the PostgreSQL Windows installer](https://www.postgresql.org/download/windows/) from their website. + + + + +If you’re using Ubuntu, you can use the following commands to download and install PostgreSQL: + +```bash +sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' +wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - +sudo apt-get update +sudo apt-get -y install postgresql +``` + +For other distributions, you can check out [PostgreSQL’s website for more guides](https://www.postgresql.org/download/linux/). + + + + +You can download PostgreSQL on your macOS using [the installer on their website](https://www.postgresql.org/download/macosx/). + + + + +### Redis + +:::info + +Redis is an open-source in-memory data structure store. It can be used for distributing and emitting messages and caching, among other purposes. + +::: + +Medusa uses Redis as the event queue in the server. If you want to use subscribers to handle events such as when an order is placed and perform actions based on the events, then you need to install and configure Redis. + +If you don’t install and configure Redis with your Medusa server, then it will work without any events-related features. + + + + +To use Redis on Windows, you must have [Windows Subsystem for Linux (WSL2) enabled](https://docs.microsoft.com/en-us/windows/wsl/install). This lets you run Linux binaries on Windows. + +After installing and enabling WSL2, if you use an Ubuntu distribution you can run the following commands to install Redis: + +```bash +sudo apt-add-repository ppa:redislabs/redis +sudo apt-get update +sudo apt-get upgrade +sudo apt-get install redis-server + +## Start Redis server +sudo service redis-server start +``` + + + + +If you use Ubuntu you can use the following commands to install Redis: + +```bash +curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg + +echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list + +sudo apt-get update +sudo apt-get install redis + +## Start Redis server +sudo service redis-server start +``` + +For other distributions, you can check out [Redis’ guide on this](https://redis.io/docs/getting-started/installation/install-redis-on-linux/). + + + + +You can install Redis on macOS using Homebrew with the following command: + +```bash +brew install redis + +## Start Redis server +brew services start redis +``` + +To install Redis without Homebrew you can check out [Redis’s guide on installing it from source](https://redis.io/docs/getting-started/installation/install-redis-from-source/). + + + + +### Medusa CLI + +The final installation required to get started with Medusa is the Medusa CLI. It is an NPM package you can install globally on your machine to get instant access to commands that help you manage and run your Medusa project. + +You can install Medusa’s CLI with the following command: + +```bash npm2yarn +npm install @medusajs/medusa-cli -g +``` + +### Code editor + +If you don't already have a code editor of choice, we recommend using [VSCode](https://code.visualstudio.com/) as it is a widely used IDE (Integrated Development Environment) by developers. + +Here are some other options: + +- [Atom](https://atom.io/) +- [Neovim](https://neovim.io/) (if you are super old school there's also plain [Vim](https://www.vim.org/)) + +It is not important which editor you use as long as you feel comfortable working with it. + +## Configuring Your Server + +After installing all the requirements mentioned above and following along with our [quickstart guide](../quickstart/quick-start.md), you need to configure some information on your server to connect it to some of the requirements you installed. + +### PostgreSQL + +After creating a new database schema in PostgreSQL, you need to add the URL to connect to it on your Medusa server. + +To do that, add the following environment variable to the `.env` file on the root of your Medusa server: + +```bash +DATABASE_URL=postgres://:@:/ +``` + +Notice that there are some details in the URL above you need to fill in yourself: + +- ``: the username of the user that has access to the database schema you created. +- ``: the password of the user that has access to the database schema you created. +- ``: the hostname where the PostgreSQL database is hosted. In local development, you can use `localhost`. +- ``: the port where the PostgreSQL database can be contacted on the host. By default, it’s 5432**.** +- ``: the name of the database schema you created. + +Then, in `medusa-config.js`, change the following properties in the object `projecConfig`: + +```jsx +module.exports = { + projectConfig: { + ..., + database_url: DATABASE_URL, + database_type: "postgres", + // comment out or remove these lines: + // database_database: "./medusa-db.sql", + // database_type: "sqlite", + }, + plugins, +}; +``` + +The last recommended step is running the following command to migrate Medusa’s database schema into your database and seed the database with dummy data: + +```bash npm2yarn +npm run seed +``` + +### Redis + +After installing Redis and running the Redis server, you must configure Medusa to use it. + +In `.env` add a new environment variable: + +```bash +REDIS_URL=redis://localhost:6379 +``` + +This is the default Redis URL to connect to, especially in development. However, if you’re deploying your server, have configured your Redis installation differently, or just need to check the format of the connection URL, you can check [this guide](https://github.com/lettuce-io/lettuce-core/wiki/Redis-URI-and-connection-details) for more details. + +:::tip + +If you use the default connection string mentioned here then you can skip over adding the environment variable. + +::: + +Then, in `medusa-config.js`, comment out the following line in the object `projectConfig`: + +```jsx +redis_url: REDIS_URL, +``` + +## What’s Next 🚀 + +- Learn how to install a storefront with [Next.js](../starters/nextjs-medusa-starter.md) or [Gatsby](./../starters/gatsby-medusa-starter.md). +- Learn how to install the [Medusa Admin](../admin/quickstart.md). diff --git a/docs/content/tutorial/development.module.css b/docs/content/tutorial/development.module.css new file mode 100644 index 0000000000..00ab3eff6b --- /dev/null +++ b/docs/content/tutorial/development.module.css @@ -0,0 +1,5 @@ +.osTabs { + background-color: #f4f4f4; + padding: 10px; + border-radius: 6.4px; +} \ No newline at end of file diff --git a/www/docs/src/theme/Tabs/index.js b/www/docs/src/theme/Tabs/index.js index 9365cb00e9..4201a3fa19 100644 --- a/www/docs/src/theme/Tabs/index.js +++ b/www/docs/src/theme/Tabs/index.js @@ -12,8 +12,8 @@ export default function TabsWrapper(props) { }, []) return ( - <> +
- +
); } From ff9ff214873a14b3b0abc419f53838b8d728eea9 Mon Sep 17 00:00:00 2001 From: adrien2p Date: Mon, 2 May 2022 13:38:15 +0200 Subject: [PATCH 07/92] feat(medusa): Update TransactionBaseService methods visibility --- packages/medusa/src/interfaces/transaction-base-service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/medusa/src/interfaces/transaction-base-service.ts b/packages/medusa/src/interfaces/transaction-base-service.ts index cbddd4b6f5..96d472bb23 100644 --- a/packages/medusa/src/interfaces/transaction-base-service.ts +++ b/packages/medusa/src/interfaces/transaction-base-service.ts @@ -31,7 +31,7 @@ export abstract class TransactionBaseService< return cloned as TChild } - shouldRetryTransaction( + protected shouldRetryTransaction_( err: { code: string } | Record ): boolean { if (!(err as { code: string })?.code) { @@ -50,7 +50,7 @@ export abstract class TransactionBaseService< * @param maybeErrorHandlerOrDontFail Potential error handler * @return the result of the transactional work */ - async atomicPhase_( + protected async atomicPhase_( work: (transactionManager: EntityManager) => Promise, isolationOrErrorHandler?: | IsolationLevel @@ -118,7 +118,7 @@ export abstract class TransactionBaseService< ) return result } catch (error) { - if (this.shouldRetryTransaction(error)) { + if (this.shouldRetryTransaction_(error)) { return this.manager_.transaction( isolation as IsolationLevel, (m): Promise => doWork(m) From 978ee98dc3f406cbacde1a1e91d26782c61b7f86 Mon Sep 17 00:00:00 2001 From: adrien2p Date: Mon, 2 May 2022 18:06:09 +0200 Subject: [PATCH 08/92] refactor(medusa): Improve proposal in cartService use arguments to pass to the super --- packages/medusa/src/services/cart.ts | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index c7baf16699..69973d4371 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -127,30 +127,8 @@ class CartService extends TransactionBaseService { lineItemAdjustmentService, priceSelectionStrategy, }: InjectedDependencies) { - super({ - manager, - cartRepository, - shippingMethodRepository, - lineItemRepository, - eventBusService, - paymentProviderService, - productService, - productVariantService, - taxProviderService, - regionService, - lineItemService, - shippingOptionService, - customerService, - discountService, - giftCardService, - totalsService, - addressRepository, - paymentSessionRepository, - inventoryService, - customShippingOptionService, - lineItemAdjustmentService, - priceSelectionStrategy, - }) + // eslint-disable-next-line prefer-rest-params + super(arguments[0]) this.manager_ = manager this.shippingMethodRepository_ = shippingMethodRepository From f7ef3aac36ed3053c8880522cc95bb046c03ebda Mon Sep 17 00:00:00 2001 From: adrien2p Date: Tue, 3 May 2022 09:22:00 +0200 Subject: [PATCH 09/92] feat(medusa): Rename base-service.spec to transaction-base-service.spec --- .../{base-service.spec.ts => transaction-base-service.spec.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/medusa/src/interfaces/__tests__/{base-service.spec.ts => transaction-base-service.spec.ts} (100%) diff --git a/packages/medusa/src/interfaces/__tests__/base-service.spec.ts b/packages/medusa/src/interfaces/__tests__/transaction-base-service.spec.ts similarity index 100% rename from packages/medusa/src/interfaces/__tests__/base-service.spec.ts rename to packages/medusa/src/interfaces/__tests__/transaction-base-service.spec.ts From 2260c2d09ec057d62b548c55142273e86eda1ca0 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Wed, 4 May 2022 13:44:37 +0700 Subject: [PATCH 10/92] fix(medusa-cli): allow spaces in develop command (#1430) --- packages/medusa/src/commands/develop.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/medusa/src/commands/develop.js b/packages/medusa/src/commands/develop.js index 05939c7829..253e175e37 100644 --- a/packages/medusa/src/commands/develop.js +++ b/packages/medusa/src/commands/develop.js @@ -5,14 +5,15 @@ import chokidar from "chokidar" import Logger from "../loaders/logger" -export default async function({ port, directory }) { +export default async function ({ port, directory }) { const args = process.argv args.shift() args.shift() args.shift() const babelPath = path.join(directory, "node_modules", ".bin", "babel") - execSync(`${babelPath} src -d dist`, { + + execSync(`"${babelPath}" src -d dist`, { cwd: directory, stdio: ["ignore", process.stdout, process.stderr], }) @@ -24,7 +25,7 @@ export default async function({ port, directory }) { stdio: ["pipe", process.stdout, process.stderr], }) - chokidar.watch(`${directory}/src`).on("change", file => { + chokidar.watch(`${directory}/src`).on("change", (file) => { const f = file.split("src")[1] Logger.info(`${f} changed: restarting...`) child.kill("SIGINT") From 3ad91741b2e729638902d7c62ac99fdfe9a40b91 Mon Sep 17 00:00:00 2001 From: adrien2p Date: Wed, 4 May 2022 16:01:04 +0200 Subject: [PATCH 11/92] feat(medusa): Move some typings into the common types --- packages/medusa/src/types/common.ts | 14 ++++++++++++++ packages/medusa/src/utils/build-query.ts | 18 +++--------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index 9837a0de1e..f094dc73b0 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -9,6 +9,20 @@ export type PartialPick = { export type Writable = { -readonly [key in keyof T]: T[key] } +export type ExtendedFindConfig = FindConfig & { + where: Partial> + withDeleted?: boolean +} + +export type Selector = { + [key in keyof TEntity]?: + | TEntity[key] + | TEntity[key][] + | DateComparisonOperator + | StringComparisonOperator + | NumericalComparisonOperator +} + export type TotalField = | "shipping_total" | "discount_total" diff --git a/packages/medusa/src/utils/build-query.ts b/packages/medusa/src/utils/build-query.ts index f116404729..f61123741d 100644 --- a/packages/medusa/src/utils/build-query.ts +++ b/packages/medusa/src/utils/build-query.ts @@ -1,20 +1,11 @@ import { - DateComparisonOperator, + ExtendedFindConfig, FindConfig, - NumericalComparisonOperator, - StringComparisonOperator, + Selector, Writable, } from "../types/common" import { FindOperator, In, Raw } from "typeorm" -type Selector = { - [key in keyof TEntity]?: TEntity[key] - | TEntity[key][] - | DateComparisonOperator - | StringComparisonOperator - | NumericalComparisonOperator -} - /** * Used to build TypeORM queries. * @param selector The selector @@ -24,10 +15,7 @@ type Selector = { export function buildQuery( selector: Selector, config: FindConfig = {} -): FindConfig & { - where: Partial> - withDeleted?: boolean -} { +): ExtendedFindConfig { const build = ( obj: Selector ): Partial> => { From 3c75a657924938f2aeda8cca7ad84a1971629ee0 Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Thu, 5 May 2022 07:56:34 +0200 Subject: [PATCH 12/92] fix(medusa): MoneyAmountRepository#findManyForVariantInRegion sql statement for constraint related to price_list (#1462) --- .../medusa/src/repositories/money-amount.ts | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/medusa/src/repositories/money-amount.ts b/packages/medusa/src/repositories/money-amount.ts index 63713651b7..496562a962 100644 --- a/packages/medusa/src/repositories/money-amount.ts +++ b/packages/medusa/src/repositories/money-amount.ts @@ -126,21 +126,17 @@ export class MoneyAmountRepository extends Repository { const date = new Date() const qb = this.createQueryBuilder("ma") - .leftJoinAndSelect( - "ma.price_list", - "price_list", - "ma.price_list_id = price_list.id " - ) + .leftJoinAndSelect("ma.price_list", "price_list") .where({ variant_id: variant_id }) .andWhere("(ma.price_list_id is null or price_list.status = 'active')") .andWhere( - "(price_list is null or price_list.ends_at is null OR price_list.ends_at > :date) ", + "(price_list.ends_at is null OR price_list.ends_at > :date)", { date: date.toUTCString(), } ) .andWhere( - "(price_list is null or price_list.starts_at is null OR price_list.starts_at < :date)", + "(price_list.starts_at is null OR price_list.starts_at < :date)", { date: date.toUTCString(), } @@ -155,23 +151,19 @@ export class MoneyAmountRepository extends Repository { ) ) } else if (!customer_id && !include_discount_prices) { - qb.andWhere("price_list IS null") + qb.andWhere("price_list.id IS null") } if (customer_id) { qb.leftJoin("price_list.customer_groups", "cgroup") - .leftJoin( - "customer_group_customers", - "cgc", - "cgc.customer_group_id = cgroup.id" - ) - .andWhere("(cgc is null OR cgc.customer_id = :customer_id)", { + .leftJoin("customer_group_customers", "cgc", "cgc.customer_group_id = cgroup.id") + .andWhere("(cgc.customer_group_id is null OR cgc.customer_id = :customer_id)", { customer_id, }) } else { - qb.leftJoin("price_list.customer_groups", "cgroup").andWhere( - "cgroup.id is null" - ) + qb + .leftJoin("price_list.customer_groups", "cgroup") + .andWhere("cgroup.id is null") } return await qb.getManyAndCount() } From c442be47d4a0b4cf3b46ce604b05e16424544046 Mon Sep 17 00:00:00 2001 From: adrien2p Date: Thu, 5 May 2022 15:07:00 +0200 Subject: [PATCH 13/92] fix(medusa): CartService lost shipping address when using the id --- packages/medusa/src/services/cart.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index 6e5d0c4284..222283509b 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -412,7 +412,7 @@ class CartService extends BaseService { } if (!data.shipping_address) { - if (region.countries.length === 1) { + if (!rawCart.shipping_address && region.countries.length === 1) { // Preselect the country if the region only has 1 // and create address entity rawCart.shipping_address = addressRepo.create({ From 32a65d27fca19b17a4650fc31536f072a0a0f49c Mon Sep 17 00:00:00 2001 From: adrien2p Date: Thu, 5 May 2022 15:28:33 +0200 Subject: [PATCH 14/92] test(medusa): Add a test to check that the shipping_address is not erase when an id is have been given --- .../medusa/src/services/__tests__/cart.js | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 571b7c26a7..20431e5f4f 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -204,7 +204,21 @@ describe("CartService", () => { }, } - const addressRepository = MockRepository({ create: (c) => c }) + const addressRepository = MockRepository({ + create: (c) => c, + findOne: (id) => { + return { + id, + first_name: "LeBron", + last_name: "James", + address_1: "Dunk St", + city: "Dunkville", + province: "CA", + postal_code: "12345", + country_code: "us", + } + } + }) const cartRepository = MockRepository() const customerService = { retrieveByEmail: jest.fn().mockReturnValue( @@ -262,6 +276,39 @@ describe("CartService", () => { expect(cartRepository.save).toHaveBeenCalledTimes(1) }) + it("successfully creates a cart with a shipping address id", async () => { + await cartService.create({ + region_id: IdMap.getId("testRegion"), + email: "email@test.com", + shipping_address_id: "test_shipping_address", + }) + + expect(eventBusService.emit).toHaveBeenCalledTimes(1) + expect(eventBusService.emit).toHaveBeenCalledWith( + "cart.created", + expect.any(Object) + ) + + expect(cartRepository.create).toHaveBeenCalledTimes(1) + expect(cartRepository.create).toHaveBeenCalledWith(expect.objectContaining({ + region_id: IdMap.getId("testRegion"), + shipping_address: { + id: "test_shipping_address", + first_name: "LeBron", + last_name: "James", + address_1: "Dunk St", + city: "Dunkville", + province: "CA", + postal_code: "12345", + country_code: "us", + }, + customer_id: IdMap.getId("customer"), + email: "email@test.com" + })) + + expect(cartRepository.save).toHaveBeenCalledTimes(1) + }) + it("creates a cart with a prefilled shipping address", async () => { const res = cartService.create({ region_id: IdMap.getId("testRegion"), From f5edaf51ea2d49d662b00f7a5360449cf827d825 Mon Sep 17 00:00:00 2001 From: adrien2p Date: Fri, 6 May 2022 10:31:02 +0200 Subject: [PATCH 15/92] fix(medusa): Proper fix of the cart service --- .../medusa/src/services/__tests__/cart.js | 4 +- packages/medusa/src/services/cart.ts | 49 ++++++++----------- 2 files changed, 22 insertions(+), 31 deletions(-) diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 20431e5f4f..0dd999c58f 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -276,7 +276,7 @@ describe("CartService", () => { expect(cartRepository.save).toHaveBeenCalledTimes(1) }) - it("successfully creates a cart with a shipping address id", async () => { + it("successfully creates a cart with a shipping address id but no shippings_address", async () => { await cartService.create({ region_id: IdMap.getId("testRegion"), email: "email@test.com", @@ -309,7 +309,7 @@ describe("CartService", () => { expect(cartRepository.save).toHaveBeenCalledTimes(1) }) - it("creates a cart with a prefilled shipping address", async () => { + it("creates a cart with a prefilled shipping address but a country not part of the region", async () => { const res = cartService.create({ region_id: IdMap.getId("testRegion"), shipping_address: { diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index 222283509b..c2cf84154f 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -393,41 +393,32 @@ class CartService extends BaseService { rawCart.region_id = region.id - if (data.shipping_address_id !== undefined) { - const shippingAddress = data.shipping_address_id - ? await addressRepo.findOne(data.shipping_address_id) - : null - - if ( - shippingAddress && - !regCountries.includes(shippingAddress.country_code) - ) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Shipping country not in region" - ) - } - - rawCart.shipping_address = shippingAddress - } - - if (!data.shipping_address) { - if (!rawCart.shipping_address && region.countries.length === 1) { - // Preselect the country if the region only has 1 - // and create address entity + if (!data.shipping_address && !data.shipping_address_id) { + if (region.countries.length === 1) { rawCart.shipping_address = addressRepo.create({ country_code: regCountries[0], }) } } else { - if (!regCountries.includes(data.shipping_address.country_code)) { - throw new MedusaError( - MedusaError.Types.NOT_ALLOWED, - "Shipping country not in region" - ) + if (data.shipping_address) { + if (!regCountries.includes(data.shipping_address.country_code)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Shipping country not in region" + ) + } + rawCart.shipping_address = data.shipping_address + } + if (data.shipping_address_id) { + const addr = await addressRepo.findOne(data.shipping_address_id) + if (addr && !regCountries.includes(addr.country_code)) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "Shipping country not in region" + ) + } + rawCart.shipping_address = addr } - - rawCart.shipping_address = data.shipping_address } const remainingFields: (keyof Cart)[] = [ From 98f5c4ec8bc9809de44223e90a36dfdfd6835503 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Fri, 6 May 2022 15:54:04 +0300 Subject: [PATCH 16/92] fix(medusa-js): Updated URLs for JS Client (#1435) --- packages/medusa-js/package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/medusa-js/package.json b/packages/medusa-js/package.json index 485566083c..f81f6f0dc4 100644 --- a/packages/medusa-js/package.json +++ b/packages/medusa-js/package.json @@ -23,10 +23,11 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/medusajs/medusa-js.git" + "url": "https://github.com/medusajs/medusa", + "directory": "packages/medusa-js" }, "bugs": { - "url": "https://github.com/medusajs/medusa-js/issues" + "url": "https://github.com/medusajs/medusa/issues" }, "devDependencies": { "@types/jest": "^26.0.19", From e2d08316dd03946453c5bf39c78a141d9fd57d3c Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Sun, 8 May 2022 18:03:29 +0700 Subject: [PATCH 17/92] fix: Use correct product price when fetching product for pricelist (#1416) --- .../api/__tests__/admin/price-list.js | 60 +++++++++++-- .../factories/simple-price-list-factory.ts | 24 ++++++ .../price-lists/list-price-list-products.ts | 85 +++++++++++++++---- .../medusa/src/repositories/money-amount.ts | 18 ++++ packages/medusa/src/services/price-list.ts | 51 +++++++++++ 5 files changed, 216 insertions(+), 22 deletions(-) diff --git a/integration-tests/api/__tests__/admin/price-list.js b/integration-tests/api/__tests__/admin/price-list.js index 0c03141bf2..e7aae7f444 100644 --- a/integration-tests/api/__tests__/admin/price-list.js +++ b/integration-tests/api/__tests__/admin/price-list.js @@ -794,9 +794,17 @@ describe("/admin/price-lists", () => { await simplePriceListFactory(dbConnection, { id: "test-list", + customer_groups: ["test-group"], prices: [ - { variant_id: "test-variant-1", currency_code: "usd", amount: 100 }, - { variant_id: "test-variant-4", currency_code: "usd", amount: 100 }, + { variant_id: "test-variant-1", currency_code: "usd", amount: 150 }, + { variant_id: "test-variant-4", currency_code: "usd", amount: 150 }, + ], + }) + await simplePriceListFactory(dbConnection, { + id: "test-list-2", + prices: [ + { variant_id: "test-variant-1", currency_code: "usd", amount: 200 }, + { variant_id: "test-variant-4", currency_code: "usd", amount: 200 }, ], }) } catch (err) { @@ -810,7 +818,7 @@ describe("/admin/price-lists", () => { await db.teardown() }) - it("lists only product 1, 2", async () => { + it("lists only product 1, 2 with price list prices", async () => { const api = useApi() const response = await api @@ -826,8 +834,50 @@ describe("/admin/price-lists", () => { expect(response.status).toEqual(200) expect(response.data.count).toEqual(2) expect(response.data.products).toEqual([ - expect.objectContaining({ id: "test-prod-1" }), - expect.objectContaining({ id: "test-prod-2" }), + expect.objectContaining({ + id: "test-prod-1", + variants: [ + expect.objectContaining({ + id: "test-variant-1", + prices: [ + expect.objectContaining({ currency_code: "usd", amount: 100 }), + expect.objectContaining({ + currency_code: "usd", + amount: 150, + price_list_id: "test-list", + }), + ], + }), + expect.objectContaining({ + id: "test-variant-2", + prices: [ + expect.objectContaining({ currency_code: "usd", amount: 100 }), + ], + }), + ], + }), + expect.objectContaining({ + id: "test-prod-2", + variants: [ + expect.objectContaining({ + id: "test-variant-3", + prices: [ + expect.objectContaining({ currency_code: "usd", amount: 100 }), + ], + }), + expect.objectContaining({ + id: "test-variant-4", + prices: [ + expect.objectContaining({ currency_code: "usd", amount: 100 }), + expect.objectContaining({ + currency_code: "usd", + amount: 150, + price_list_id: "test-list", + }), + ], + }), + ], + }), ]) }) diff --git a/integration-tests/api/factories/simple-price-list-factory.ts b/integration-tests/api/factories/simple-price-list-factory.ts index 439b35deec..a20380d3b0 100644 --- a/integration-tests/api/factories/simple-price-list-factory.ts +++ b/integration-tests/api/factories/simple-price-list-factory.ts @@ -3,6 +3,7 @@ import { MoneyAmount, PriceListType, PriceListStatus, + CustomerGroup, } from "@medusajs/medusa" import faker from "faker" import { Connection } from "typeorm" @@ -38,6 +39,28 @@ export const simplePriceListFactory = async ( const manager = connection.manager const listId = data.id || `simple-price-list-${Math.random() * 1000}` + + let customerGroups = [] + if (typeof data.customer_groups !== "undefined") { + await manager + .createQueryBuilder() + .insert() + .into(CustomerGroup) + .values( + data.customer_groups.map((group) => ({ + id: group, + name: faker.company.companyName(), + })) + ) + .orIgnore() + .execute() + + customerGroups = await manager.findByIds( + CustomerGroup, + data.customer_groups + ) + } + const toCreate = { id: listId, name: data.name || faker.commerce.productName(), @@ -46,6 +69,7 @@ export const simplePriceListFactory = async ( type: data.type || PriceListType.OVERRIDE, starts_at: data.starts_at || null, ends_at: data.ends_at || null, + customer_groups: customerGroups, } const toSave = manager.create(PriceList, toCreate) diff --git a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts index 5cbe0abba6..21a3f73b66 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/list-price-list-products.ts @@ -1,5 +1,5 @@ import { Type } from "class-transformer" -import { omit } from "lodash" +import { omit, pickBy } from "lodash" import { IsArray, IsBoolean, @@ -19,6 +19,9 @@ import { defaultAdminProductRelations, } from "../products" import listAndCount from "../../../../controllers/products/admin-list-products" +import { MedusaError } from "medusa-core-utils" +import { getListConfig } from "../../../../utils/get-query-config" +import PriceListService from "../../../../services/price-list" /** * @oas [get] /price-lists/:id/products @@ -78,7 +81,7 @@ export default async (req, res) => { req.query.price_list_id = [id] - const filterableFields: FilterableProductProps = omit(req.query, [ + const query: FilterableProductProps = omit(req.query, [ "limit", "offset", "expand", @@ -86,23 +89,71 @@ export default async (req, res) => { "order", ]) - const result = await listAndCount( - req.scope, - filterableFields, - {}, - { - limit: validatedParams.limit ?? 50, - offset: validatedParams.offset ?? 0, - expand: validatedParams.expand, - fields: validatedParams.fields, - order: validatedParams.order, - allowedFields: allowedAdminProductFields, - defaultFields: defaultAdminProductFields as (keyof Product)[], - defaultRelations: defaultAdminProductRelations, - } + const limit = validatedParams.limit ?? 50 + const offset = validatedParams.offset ?? 0 + const expand = validatedParams.expand + const fields = validatedParams.fields + const order = validatedParams.order + const allowedFields = allowedAdminProductFields + const defaultFields = defaultAdminProductFields as (keyof Product)[] + const defaultRelations = defaultAdminProductRelations.filter( + (r) => r !== "variants.prices" ) - res.json(result) + const priceListService: PriceListService = + req.scope.resolve("priceListService") + + let includeFields: (keyof Product)[] | undefined + if (fields) { + includeFields = fields.split(",") as (keyof Product)[] + } + + let expandFields: string[] | undefined + if (expand) { + expandFields = expand.split(",") + } + + let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined + if (typeof order !== "undefined") { + let orderField = order + if (order.startsWith("-")) { + const [, field] = order.split("-") + orderField = field + orderBy = { [field]: "DESC" } + } else { + orderBy = { [order]: "ASC" } + } + + if (!(allowedFields || []).includes(orderField)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Order field must be a valid product field" + ) + } + } + + const listConfig = getListConfig( + defaultFields ?? [], + defaultRelations ?? [], + includeFields, + expandFields, + limit, + offset, + orderBy + ) + + const [products, count] = await priceListService.listProducts( + id, + pickBy(query, (val) => typeof val !== "undefined"), + listConfig + ) + + res.json({ + products, + count, + offset, + limit, + }) } enum ProductStatus { diff --git a/packages/medusa/src/repositories/money-amount.ts b/packages/medusa/src/repositories/money-amount.ts index 496562a962..716db232cf 100644 --- a/packages/medusa/src/repositories/money-amount.ts +++ b/packages/medusa/src/repositories/money-amount.ts @@ -116,6 +116,24 @@ export class MoneyAmountRepository extends Repository { .execute() } + public async findManyForVariantInPriceList( + variant_id: string, + price_list_id: string + ): Promise<[MoneyAmount[], number]> { + const qb = this.createQueryBuilder("ma") + .leftJoinAndSelect("ma.price_list", "price_list") + .where("ma.variant_id = :variant_id", { variant_id }) + .andWhere( + new Brackets((qb) => { + qb.where("ma.price_list_id = :price_list_id", { + price_list_id, + }).orWhere("ma.price_list_id IS NULL") + }) + ) + + return await qb.getManyAndCount() + } + public async findManyForVariantInRegion( variant_id: string, region_id?: string, diff --git a/packages/medusa/src/services/price-list.ts b/packages/medusa/src/services/price-list.ts index 0312b6e81e..6c96e8508e 100644 --- a/packages/medusa/src/services/price-list.ts +++ b/packages/medusa/src/services/price-list.ts @@ -2,6 +2,7 @@ import { MedusaError } from "medusa-core-utils" import { BaseService } from "medusa-interfaces" import { EntityManager } from "typeorm" import { CustomerGroupService } from "." +import { Product } from "../models" import { CustomerGroup } from "../models/customer-group" import { PriceList } from "../models/price-list" import { MoneyAmountRepository } from "../repositories/money-amount" @@ -14,10 +15,12 @@ import { UpdatePriceListInput, } from "../types/price-list" import { formatException } from "../utils/exception-formatter" +import ProductService from "./product" type PriceListConstructorProps = { manager: EntityManager customerGroupService: CustomerGroupService + productService: ProductService priceListRepository: typeof PriceListRepository moneyAmountRepository: typeof MoneyAmountRepository } @@ -29,18 +32,21 @@ type PriceListConstructorProps = { class PriceListService extends BaseService { private manager_: EntityManager private customerGroupService_: CustomerGroupService + private productService_: ProductService private priceListRepo_: typeof PriceListRepository private moneyAmountRepo_: typeof MoneyAmountRepository constructor({ manager, customerGroupService, + productService, priceListRepository, moneyAmountRepository, }: PriceListConstructorProps) { super() this.manager_ = manager this.customerGroupService_ = customerGroupService + this.productService_ = productService this.priceListRepo_ = priceListRepository this.moneyAmountRepo_ = moneyAmountRepository } @@ -53,6 +59,7 @@ class PriceListService extends BaseService { const cloned = new PriceListService({ manager: transactionManager, customerGroupService: this.customerGroupService_, + productService: this.productService_, priceListRepository: this.priceListRepo_, moneyAmountRepository: this.moneyAmountRepo_, }) @@ -276,6 +283,50 @@ class PriceListService extends BaseService { await priceListRepo.save(priceList) } + + async listProducts( + priceListId: string, + selector = {}, + config: FindConfig = { + relations: [], + skip: 0, + take: 20, + } + ): Promise<[Product[], number]> { + return await this.atomicPhase_(async (manager: EntityManager) => { + const [products, count] = await this.productService_.listAndCount( + selector, + config + ) + + const moneyAmountRepo = manager.getCustomRepository(this.moneyAmountRepo_) + + const productsWithPrices = await Promise.all( + products.map(async (p) => { + if (p.variants?.length) { + p.variants = await Promise.all( + p.variants.map(async (v) => { + const [prices] = + await moneyAmountRepo.findManyForVariantInPriceList( + v.id, + priceListId + ) + + return { + ...v, + prices, + } + }) + ) + } + + return p + }) + ) + + return [productsWithPrices, count] + }) + } } export default PriceListService From e7cb76ab6e13fe756e090fd1a0a3ff645c30c69a Mon Sep 17 00:00:00 2001 From: Kasper Fabricius Kristensen <45367945+kasperkristensen@users.noreply.github.com> Date: Sun, 8 May 2022 16:12:31 +0200 Subject: [PATCH 18/92] fix: Cascade remove prices + option values on variant and product delete (#1465) --- .../api/__tests__/admin/product.js | 173 +++++++++++++++++- .../medusa/src/services/product-variant.ts | 2 +- packages/medusa/src/services/product.js | 2 +- 3 files changed, 174 insertions(+), 3 deletions(-) diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index df71a7530f..831a12296d 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -6,7 +6,7 @@ const { initDb, useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") const productSeeder = require("../../helpers/product-seeder") -const { ProductVariant } = require("@medusajs/medusa") +const { ProductVariant, ProductOptionValue, MoneyAmount } = require("@medusajs/medusa") const priceListSeeder = require("../../helpers/price-list-seeder") jest.setTimeout(50000) @@ -1737,6 +1737,177 @@ describe("/admin/products", () => { expect(variant).toEqual(undefined) }) + it("successfully deletes a product variant and its associated option values", async () => { + const api = useApi() + + // Validate that the option value exists + const optValPre = await dbConnection.manager.findOne(ProductOptionValue, { + variant_id: "test-variant_2", + }) + + expect(optValPre).not.toEqual(undefined) + + // Soft delete the variant + const response = await api.delete( + "/admin/products/test-product/variants/test-variant_2", + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + + expect(response.status).toEqual(200) + + // Validate that the option value was deleted + const optValPost = await dbConnection.manager.findOne( + ProductOptionValue, + { + variant_id: "test-variant_2", + } + ) + + expect(optValPost).toEqual(undefined) + + // Validate that the option still exists in the DB with deleted_at + const optValDeleted = await dbConnection.manager.findOne(ProductOptionValue, { + variant_id: "test-variant_2", + }, { + withDeleted: true, + }) + + expect(optValDeleted).toEqual(expect.objectContaining({ + deleted_at: expect.any(Date), + variant_id: "test-variant_2", + })) + }) + + it("successfully deletes a product and any option value associated with one of its variants", async () => { + const api = useApi() + + // Validate that the option value exists + const optValPre = await dbConnection.manager.findOne(ProductOptionValue, { + variant_id: "test-variant_2", + }) + + expect(optValPre).not.toEqual(undefined) + + // Soft delete the product + const response = await api.delete("/admin/products/test-product", { + headers: { + Authorization: "Bearer test_token", + }, + }) + + expect(response.status).toEqual(200) + + // Validate that the option value has been deleted + const optValPost = await dbConnection.manager.findOne( + ProductOptionValue, + { + variant_id: "test-variant_2", + } + ) + + expect(optValPost).toEqual(undefined) + + // Validate that the option still exists in the DB with deleted_at + const optValDeleted = await dbConnection.manager.findOne(ProductOptionValue, { + variant_id: "test-variant_2", + }, { + withDeleted: true, + }) + + expect(optValDeleted).toEqual(expect.objectContaining({ + deleted_at: expect.any(Date), + variant_id: "test-variant_2", + })) + }) + + it("successfully deletes a product variant and its associated prices", async () => { + const api = useApi() + + // Validate that the price exists + const pricePre = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }) + + expect(pricePre).not.toEqual(undefined) + + // Soft delete the variant + const response = await api.delete( + "/admin/products/test-product/variants/test-variant", + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + + expect(response.status).toEqual(200) + + // Validate that the price was deleted + const pricePost = await dbConnection.manager.findOne( + MoneyAmount, + { + id: "test-price", + } + ) + + expect(pricePost).toEqual(undefined) + + // Validate that the price still exists in the DB with deleted_at + const optValDeleted = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }, { + withDeleted: true, + }) + + expect(optValDeleted).toEqual(expect.objectContaining({ + deleted_at: expect.any(Date), + id: "test-price", + })) + }) + + it("successfully deletes a product and any prices associated with one of its variants", async () => { + const api = useApi() + + // Validate that the price exists + const pricePre = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }) + + expect(pricePre).not.toEqual(undefined) + + // Soft delete the product + const response = await api.delete("/admin/products/test-product", { + headers: { + Authorization: "Bearer test_token", + }, + }) + + expect(response.status).toEqual(200) + + // Validate that the price has been deleted + const pricePost = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }) + + expect(pricePost).toEqual(undefined) + + // Validate that the price still exists in the DB with deleted_at + const optValDeleted = await dbConnection.manager.findOne(MoneyAmount, { + id: "test-price", + }, { + withDeleted: true, + }) + + expect(optValDeleted).toEqual(expect.objectContaining({ + deleted_at: expect.any(Date), + id: "test-price", + })) + }) + it("successfully creates product with soft-deleted product handle and deletes it again", async () => { const api = useApi() diff --git a/packages/medusa/src/services/product-variant.ts b/packages/medusa/src/services/product-variant.ts index 62fe890150..2d8f6cf4af 100644 --- a/packages/medusa/src/services/product-variant.ts +++ b/packages/medusa/src/services/product-variant.ts @@ -751,7 +751,7 @@ class ProductVariantService extends BaseService { const variant = await variantRepo.findOne({ where: { id: variantId }, - relations: ["prices"], + relations: ["prices", "options"], }) if (!variant) { diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index 5e1da6caa8..b746e2e049 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -668,7 +668,7 @@ class ProductService extends BaseService { // Should not fail, if product does not exist, since delete is idempotent const product = await productRepo.findOne( { id: productId }, - { relations: ["variants"] } + { relations: ["variants", "variants.prices", "variants.options"] } ) if (!product) { From f71b9b3a8733fdcfe4298fcf49fd06ae89850fc2 Mon Sep 17 00:00:00 2001 From: Zakaria El Asri <33696020+zakariaelas@users.noreply.github.com> Date: Sun, 8 May 2022 17:45:18 +0100 Subject: [PATCH 19/92] fix(medusa): support searching for price lists (#1407) --- .../api/__tests__/admin/price-list.js | 111 ++++++++++++++++++ integration-tests/api/package.json | 6 +- integration-tests/api/yarn.lock | 71 ++++++----- .../medusa/src/repositories/price-list.ts | 94 ++++++++++++++- packages/medusa/src/services/price-list.ts | 13 +- packages/medusa/src/types/common.ts | 17 +++ 6 files changed, 268 insertions(+), 44 deletions(-) diff --git a/integration-tests/api/__tests__/admin/price-list.js b/integration-tests/api/__tests__/admin/price-list.js index e7aae7f444..b1c39b9819 100644 --- a/integration-tests/api/__tests__/admin/price-list.js +++ b/integration-tests/api/__tests__/admin/price-list.js @@ -211,6 +211,117 @@ describe("/admin/price-lists", () => { ]) ) }) + + it("given a search query, returns matching results by name", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=winter", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "VIP winter sale", + }), + ]) + ) + expect(response.data.count).toEqual(1) + }) + + it("given a search query, returns matching results by description", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=25%", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "VIP winter sale", + description: + "Winter sale for VIP customers. 25% off selected items.", + }), + ]) + ) + expect(response.data.count).toEqual(1) + }) + + it("given a search query, returns empty list when does not exist", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=blablabla", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual([]) + expect(response.data.count).toEqual(0) + }) + + it("given a search query and a status filter not matching any price list, returns an empty set", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=vip&status[]=draft", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual([]) + expect(response.data.count).toEqual(0) + }) + + it("given a search query and a status filter matching a price list, returns a price list", async () => { + const api = useApi() + + const response = await api + .get("/admin/price-lists?q=vip&status[]=active", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "VIP winter sale", + status: "active", + }), + ]) + ) + expect(response.data.count).toEqual(1) + }) }) describe("POST /admin/price-lists/:id", () => { diff --git a/integration-tests/api/package.json b/integration-tests/api/package.json index 42e6e05c96..40318b328f 100644 --- a/integration-tests/api/package.json +++ b/integration-tests/api/package.json @@ -8,16 +8,16 @@ "build": "babel src -d dist --extensions \".ts,.js\"" }, "dependencies": { - "@medusajs/medusa": "1.2.1-dev-1649181615374", + "@medusajs/medusa": "1.2.1-dev-1650573289860", "faker": "^5.5.3", - "medusa-interfaces": "1.2.1-dev-1649181615374", + "medusa-interfaces": "1.2.1-dev-1650573289860", "typeorm": "^0.2.31" }, "devDependencies": { "@babel/cli": "^7.12.10", "@babel/core": "^7.12.10", "@babel/node": "^7.12.10", - "babel-preset-medusa-package": "1.1.19-dev-1649181615374", + "babel-preset-medusa-package": "1.1.19-dev-1650573289860", "jest": "^26.6.3" } } diff --git a/integration-tests/api/yarn.lock b/integration-tests/api/yarn.lock index b81b1094d6..b83de4e288 100644 --- a/integration-tests/api/yarn.lock +++ b/integration-tests/api/yarn.lock @@ -1301,10 +1301,10 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@medusajs/medusa-cli@1.2.1-dev-1649181615374": - version "1.2.1-dev-1649181615374" - resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.1-dev-1649181615374.tgz#1ea9014e3ec9813a52457b0d6e2fc6bb64d3bfd6" - integrity sha512-8m6Z1ZZqstZKaAaKoFS3v3IzI7BFhcBgpF+iCSRuJoXltQgzVQOAxXuPjkRoi+m1ZZ+Yi/YYEzKmNQ99vmXisQ== +"@medusajs/medusa-cli@1.2.1-dev-1650573289860": + version "1.2.1-dev-1650573289860" + resolved "http://localhost:4873/@medusajs%2fmedusa-cli/-/medusa-cli-1.2.1-dev-1650573289860.tgz#7685e4add2985e95fd945f6e7154f6ecb175d565" + integrity sha512-RpLR/uM/HfEEFtlZmeImT295ohpSCOKTrWcKVXj8UT6L4jj+FJ0SpcW3Fnr2Q5kOulQVL3qXx5t79Nz02O4qvw== dependencies: "@babel/polyfill" "^7.8.7" "@babel/runtime" "^7.9.6" @@ -1322,8 +1322,8 @@ is-valid-path "^0.1.1" joi-objectid "^3.0.1" meant "^1.0.1" - medusa-core-utils "1.1.31-dev-1649181615374" - medusa-telemetry "0.0.11-dev-1649181615374" + medusa-core-utils "1.1.31-dev-1650573289860" + medusa-telemetry "0.0.11-dev-1650573289860" netrc-parser "^3.1.6" open "^8.0.6" ora "^5.4.1" @@ -1337,13 +1337,13 @@ winston "^3.3.3" yargs "^15.3.1" -"@medusajs/medusa@1.2.1-dev-1649181615374": - version "1.2.1-dev-1649181615374" - resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.1-dev-1649181615374.tgz#6a62f8628b84b47a8717e9e0c276f3a9c2e376ce" - integrity sha512-eiCGE6JqYuP7GCzTBGg5LI9U0uQ0wlsR+NuMZVEwldj+xc7qwMjBJwUA7gc58gBv6JesfMYj3VZmJComN4+7Bg== +"@medusajs/medusa@1.2.1-dev-1650573289860": + version "1.2.1-dev-1650573289860" + resolved "http://localhost:4873/@medusajs%2fmedusa/-/medusa-1.2.1-dev-1650573289860.tgz#740d20bf349be9ad9e0f151305a75bd40284b641" + integrity sha512-kU3l95SjU/B4SQLhof2obdXbWwkMcRKDnSXE2CB4G1a9jchwnzfRDVDy2MjO/4/0l6AgTLHwxoBR8F9zYUsiDQ== dependencies: "@hapi/joi" "^16.1.8" - "@medusajs/medusa-cli" "1.2.1-dev-1649181615374" + "@medusajs/medusa-cli" "1.2.1-dev-1650573289860" "@types/lodash" "^4.14.168" awilix "^4.2.3" body-parser "^1.19.0" @@ -1356,7 +1356,6 @@ core-js "^3.6.5" cors "^2.8.5" cross-spawn "^7.0.3" - dotenv "^8.2.0" express "^4.17.1" express-session "^1.17.1" fs-exists-cached "^1.0.0" @@ -1367,8 +1366,8 @@ joi "^17.3.0" joi-objectid "^3.0.1" jsonwebtoken "^8.5.1" - medusa-core-utils "1.1.31-dev-1649181615374" - medusa-test-utils "1.1.37-dev-1649181615374" + medusa-core-utils "1.1.31-dev-1650573289860" + medusa-test-utils "1.1.37-dev-1650573289860" morgan "^1.9.1" multer "^1.4.2" passport "^0.4.0" @@ -2010,10 +2009,10 @@ babel-preset-jest@^26.6.2: babel-plugin-jest-hoist "^26.6.2" babel-preset-current-node-syntax "^1.0.0" -babel-preset-medusa-package@1.1.19-dev-1649181615374: - version "1.1.19-dev-1649181615374" - resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1649181615374.tgz#2f13d52fedd336ad4b4c0602b3bf4696d2d08db7" - integrity sha512-N4XL7rTmNM2W+iRR92xvU4bKadP25lY5QR3vndxTxsLNSgcR5tLjKLO/4j7AqiFvcthbE8cF1TcdECH5aJfSuA== +babel-preset-medusa-package@1.1.19-dev-1650573289860: + version "1.1.19-dev-1650573289860" + resolved "http://localhost:4873/babel-preset-medusa-package/-/babel-preset-medusa-package-1.1.19-dev-1650573289860.tgz#493490de8ca1ce75b30545f19fdb9544b6324af4" + integrity sha512-++eqULlSbdH4bnwi/edLa097io4sxZvJSaXIwSC0it7GDB3IXITk5xyuxgKCJue3psSexwuUV58i/or8sZ1idg== dependencies: "@babel/plugin-proposal-class-properties" "^7.12.1" "@babel/plugin-proposal-decorators" "^7.12.1" @@ -5156,25 +5155,23 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -medusa-core-utils@1.1.31-dev-1649181615374: - version "1.1.31-dev-1649181615374" - resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1649181615374.tgz#60416bba53eaba607d77ca36789aa23756b8db0f" - integrity sha512-w5nusocZweIrAFJ6sl4hD/mN+UtNjz39IIfXukekyJByg3wpv4P+vsW3XdrFTX5OLvKVxuzjl3B2zeZUmdgSKg== +medusa-core-utils@1.1.31-dev-1650573289860: + version "1.1.31-dev-1650573289860" + resolved "http://localhost:4873/medusa-core-utils/-/medusa-core-utils-1.1.31-dev-1650573289860.tgz#4b6ce1ab888a1b56dc08657e1c9ec0686678d7c1" + integrity sha512-y4Xy9Z+LQAXK4CzGzrC+sn0ngTfZgzIbVTUFVi2YhjRrjCXP36Caisf7e5gEkvmC8TxFsl061mneTcZBX+ni9g== dependencies: joi "^17.3.0" joi-objectid "^3.0.1" -medusa-interfaces@1.2.1-dev-1649181615374: - version "1.2.1-dev-1649181615374" - resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.1-dev-1649181615374.tgz#0b664f4e3e8e61b67108a41c8f0f9dd58a947075" - integrity sha512-JRD773nZnxjn/2oNrgb/zXn+scBoNHpW97YQYW7+LFX1JBYafzyGQW3vWTFX4X+q08ehz4dc21CgoMeYco8yvQ== - dependencies: - medusa-core-utils "1.1.31-dev-1649181615374" +medusa-interfaces@1.2.1-dev-1650573289860: + version "1.2.1-dev-1650573289860" + resolved "http://localhost:4873/medusa-interfaces/-/medusa-interfaces-1.2.1-dev-1650573289860.tgz#088ef6571cf3ec4b77de716bb6a3897f5a7a3de7" + integrity sha512-/WFMXz6iZp8tau6V/eVYao4SoIyYDrIUKXx32dfFibsQdnf8ev2CL08iTncfmWgAdlHNlO3lMJKF4arEdv3QTQ== -medusa-telemetry@0.0.11-dev-1649181615374: - version "0.0.11-dev-1649181615374" - resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1649181615374.tgz#3f4c366ea8d0d0fdde9b289f7e771bb27adae56d" - integrity sha512-RMJR3/qlTb1nV05RnBnX1bNOvYyeuXf4owxLlfbWzKAZirWQ5LAC2GikEGGbHGbw7UgiLgQtu4Rnmg2Uye+VcA== +medusa-telemetry@0.0.11-dev-1650573289860: + version "0.0.11-dev-1650573289860" + resolved "http://localhost:4873/medusa-telemetry/-/medusa-telemetry-0.0.11-dev-1650573289860.tgz#d74c00da87adc4e0105047db519dcbbddc36475e" + integrity sha512-UFOGj3hKpfJLKIaQMZQqb2DlGs0gScPJdrgMa5GQFwOGxcYCN2D6gyWtIWuKEDZ8oG4X8MCE0zTa3r+Sh4+zPQ== dependencies: axios "^0.21.1" axios-retry "^3.1.9" @@ -5186,13 +5183,13 @@ medusa-telemetry@0.0.11-dev-1649181615374: remove-trailing-slash "^0.1.1" uuid "^8.3.2" -medusa-test-utils@1.1.37-dev-1649181615374: - version "1.1.37-dev-1649181615374" - resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1649181615374.tgz#079c16a791d47c52072c6f0837d0a827208bc9cc" - integrity sha512-hj3iNZsIA01l7qAZrOgt+kT8PDkXKoW4CEL3bhVfIUEwsdv9jID7FGdTIN/7G3diioTypvrVcqRrp0uiWdgp+Q== +medusa-test-utils@1.1.37-dev-1650573289860: + version "1.1.37-dev-1650573289860" + resolved "http://localhost:4873/medusa-test-utils/-/medusa-test-utils-1.1.37-dev-1650573289860.tgz#1c8705617b64c4a474891f99985044faa5f8fa2f" + integrity sha512-MnKhy7hbNcZdYjVm9B3Z9MAT7MTf4oTdqQR9/lmb4qd7dJqd2AbxhglsOn5ZRYQHynuzubS+j9EHkxgOgji8MQ== dependencies: "@babel/plugin-transform-classes" "^7.9.5" - medusa-core-utils "1.1.31-dev-1649181615374" + medusa-core-utils "1.1.31-dev-1650573289860" randomatic "^3.1.1" merge-descriptors@1.0.1: diff --git a/packages/medusa/src/repositories/price-list.ts b/packages/medusa/src/repositories/price-list.ts index bcf37926bf..0eac64a2d2 100644 --- a/packages/medusa/src/repositories/price-list.ts +++ b/packages/medusa/src/repositories/price-list.ts @@ -1,5 +1,95 @@ -import { EntityRepository, Repository } from "typeorm" +import { groupBy, map } from "lodash" +import { + Brackets, + EntityRepository, + FindManyOptions, Repository +} from "typeorm" import { PriceList } from "../models/price-list" +import { CustomFindOptions } from "../types/common" + +type PriceListFindOptions = CustomFindOptions @EntityRepository(PriceList) -export class PriceListRepository extends Repository {} +export class PriceListRepository extends Repository { + public async getFreeTextSearchResultsAndCount( + q: string, + options: PriceListFindOptions = { where: {} }, + relations: (keyof PriceList)[] = [] + ): Promise<[PriceList[], number]> { + options.where = options.where ?? {} + let qb = this.createQueryBuilder("price_list") + .leftJoinAndSelect("price_list.customer_groups", "customer_group") + .select(["price_list.id"]) + .where(options.where) + .andWhere( + new Brackets((qb) => { + qb.where(`price_list.description ILIKE :q`, { q: `%${q}%` }) + .orWhere(`price_list.name ILIKE :q`, { q: `%${q}%` }) + .orWhere(`customer_group.name ILIKE :q`, { q: `%${q}%` }) + }) + ) + .skip(options.skip) + .take(options.take) + + const [results, count] = await qb.getManyAndCount() + + const price_lists = await this.findWithRelations( + relations, + results.map((r) => r.id) + ) + + return [price_lists, count] + } + + public async findWithRelations( + relations: (keyof PriceList)[] = [], + idsOrOptionsWithoutRelations: + | Omit, "relations"> + | string[] = {} + ): Promise { + let entities + if (Array.isArray(idsOrOptionsWithoutRelations)) { + entities = await this.findByIds(idsOrOptionsWithoutRelations) + } else { + entities = await this.find(idsOrOptionsWithoutRelations) + } + + const groupedRelations: Record = {} + for (const relation of relations) { + const [topLevel] = relation.split(".") + if (groupedRelations[topLevel]) { + groupedRelations[topLevel].push(relation) + } else { + groupedRelations[topLevel] = [relation] + } + } + + const entitiesIds = entities.map(({ id }) => id) + const entitiesIdsWithRelations = await Promise.all( + Object.values(groupedRelations).map((relations: string[]) => { + return this.findByIds(entitiesIds, { + select: ["id"], + relations: relations as string[], + }) + }) + ).then(entitiesIdsWithRelations => entitiesIdsWithRelations.flat()) + const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) + + const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id") + return map(entitiesAndRelationsById, (entityAndRelations) => + this.merge(this.create(), ...entityAndRelations) + ) + } + + public async findOneWithRelations( + relations: (keyof PriceList)[] = [], + options: Omit, "relations"> = {} + ): Promise { + options.take = 1 + + return (await this.findWithRelations( + relations, + options + ))?.pop() + } +} diff --git a/packages/medusa/src/services/price-list.ts b/packages/medusa/src/services/price-list.ts index 6c96e8508e..38bae29080 100644 --- a/packages/medusa/src/services/price-list.ts +++ b/packages/medusa/src/services/price-list.ts @@ -260,9 +260,18 @@ class PriceListService extends BaseService { config: FindConfig = { skip: 0, take: 20 } ): Promise<[PriceList[], number]> { const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_) + const q = selector.q + const { relations, ...query } = this.buildQuery_(selector, config) - const query = this.buildQuery_(selector, config) - return await priceListRepo.findAndCount(query) + if (q) { + delete query.where.q + return await priceListRepo.getFreeTextSearchResultsAndCount( + q, + query, + relations + ) + } + return await priceListRepo.findAndCount({ ...query, relations }) } async upsertCustomerGroups_( diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index 94f06c01e1..98bd6fde7d 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -1,6 +1,12 @@ import { Transform, Type } from "class-transformer" import { IsDate, IsNumber, IsOptional, IsString } from "class-validator" import "reflect-metadata" +import { + BaseEntity, + FindManyOptions, + FindOperator, + OrderByCondition, +} from "typeorm" import { transformDate } from "../utils/validators/date-transform" export type PartialPick = { @@ -25,6 +31,17 @@ export interface FindConfig { order?: Record } +export interface CustomFindOptions { + select?: FindManyOptions["select"] + where?: FindManyOptions["where"] & + { + [P in InKeys]?: TModel[P][] + } + order?: OrderByCondition + skip?: number + take?: number +} + export type PaginatedResponse = { limit: number; offset: number; count: number } export type DeleteResponse = { From 525910f72aa76355c29dd153f28ea08221956f3e Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 9 May 2022 09:41:18 +0200 Subject: [PATCH 20/92] fix(medusa-file-spaces): Allow duplicate filenames (#1474) --- packages/medusa-file-spaces/src/services/digital-ocean.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/medusa-file-spaces/src/services/digital-ocean.js b/packages/medusa-file-spaces/src/services/digital-ocean.js index 82e6a66e77..d9ef58e2e7 100644 --- a/packages/medusa-file-spaces/src/services/digital-ocean.js +++ b/packages/medusa-file-spaces/src/services/digital-ocean.js @@ -1,5 +1,6 @@ import fs from "fs" import aws from "aws-sdk" +import { parse } from "path" import { FileService } from "medusa-interfaces" class DigitalOceanService extends FileService { @@ -23,12 +24,14 @@ class DigitalOceanService extends FileService { endpoint: this.endpoint_, }) + const parsedFilename = parse(file.originalname) + const fileKey = `${parsedFilename.name}-${Date.now()}${parsedFilename.ext}` const s3 = new aws.S3() var params = { ACL: "public-read", Bucket: this.bucket_, Body: fs.createReadStream(file.path), - Key: `${file.originalname}`, + Key: fileKey, } return new Promise((resolve, reject) => { From c67d6bee303ad8e43f320fe16f807b0d22890c5b Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 9 May 2022 09:42:47 +0200 Subject: [PATCH 21/92] fix(medusa): PluginLoaders when loading services should only look for js files (#1473) --- .../src/loaders/__tests__/plugins.spec.ts | 61 +++++++++++++++++++ packages/medusa/src/loaders/plugins.ts | 4 +- 2 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 packages/medusa/src/loaders/__tests__/plugins.spec.ts diff --git a/packages/medusa/src/loaders/__tests__/plugins.spec.ts b/packages/medusa/src/loaders/__tests__/plugins.spec.ts new file mode 100644 index 0000000000..e193a70d8c --- /dev/null +++ b/packages/medusa/src/loaders/__tests__/plugins.spec.ts @@ -0,0 +1,61 @@ +import { createContainer, asValue } from "awilix" +import { mkdirSync, rmSync, rmdirSync, writeFileSync } from "fs" +import { resolve } from "path" +import Logger from "../logger" +import { registerServices } from "../plugins" +import { MedusaContainer } from "../../types/global" + +const distTestTargetDirectorPath = resolve(__dirname, "__pluginsLoaderTest__") +const servicesTestTargetDirectoryPath = resolve(distTestTargetDirectorPath, "services") +const buildServiceTemplate = (name: string) => { + return ` + import { BaseService } from "medusa-interfaces" + export default class ${name}Service extends BaseService {} + ` +} + +describe('plugins loader', () => { + const container = createContainer() as MedusaContainer + const pluginsDetails = { + resolve: resolve(__dirname, "__pluginsLoaderTest__"), + name: `project-plugin`, + id: "fakeId", + options: {}, + version: '"fakeVersion', + } + + describe("registerServices", function() { + beforeAll(() => { + container.register("logger", asValue(Logger)) + mkdirSync(servicesTestTargetDirectoryPath, { mode: "777", recursive: true }) + writeFileSync(resolve(servicesTestTargetDirectoryPath, "test.js"), buildServiceTemplate("test")) + writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.js"), buildServiceTemplate("test2")) + writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.js.map"), "map:file") + writeFileSync(resolve(servicesTestTargetDirectoryPath, "test2.d.ts"), "export interface Test {}") + }) + + afterAll(() => { + rmSync(distTestTargetDirectorPath, { recursive: true, force: true }) + jest.clearAllMocks() + }) + + it('should load the services from the services directory but only js files', async () => { + let err; + try { + await registerServices(pluginsDetails, container) + } catch (e) { + err = e + } + + expect(err).toBeFalsy() + + const testService: (...args: unknown[]) => any = container.resolve("testService") + const test2Service: (...args: unknown[]) => any = container.resolve("test2Service") + + expect(testService).toBeTruthy() + expect(testService.constructor.name).toBe("testService") + expect(test2Service).toBeTruthy() + expect(test2Service.constructor.name).toBe("test2Service") + }) + }) +}) \ No newline at end of file diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index ae831ddbeb..7c24df9b17 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -245,8 +245,8 @@ function registerApi( * registered * @return {void} */ -async function registerServices(pluginDetails: PluginDetails, container: MedusaContainer): Promise { - const files = glob.sync(`${pluginDetails.resolve}/services/[!__]*`, {}) +export async function registerServices(pluginDetails: PluginDetails, container: MedusaContainer): Promise { + const files = glob.sync(`${pluginDetails.resolve}/services/[!__]*.js`, {}) await Promise.all( files.map(async (fn) => { const loaded = require(fn).default From 90870292c62e2e96ca8c9ffc51e32c73493e0c0b Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Mon, 9 May 2022 09:52:15 +0200 Subject: [PATCH 22/92] fix(medusa): Remove line-item.js file (#1414) --- .../src/services/__tests__/line-item.js | 15 +- packages/medusa/src/services/line-item.js | 282 ------------------ packages/medusa/src/services/line-item.ts | 4 +- 3 files changed, 11 insertions(+), 290 deletions(-) delete mode 100644 packages/medusa/src/services/line-item.js diff --git a/packages/medusa/src/services/__tests__/line-item.js b/packages/medusa/src/services/__tests__/line-item.js index 0642e9791d..d36bd8363a 100644 --- a/packages/medusa/src/services/__tests__/line-item.js +++ b/packages/medusa/src/services/__tests__/line-item.js @@ -3,7 +3,9 @@ import LineItemService from "../line-item" describe("LineItemService", () => { describe("create", () => { - const lineItemRepository = MockRepository({}) + const lineItemRepository = MockRepository({ + create: (data) => data + }) const cartRepository = MockRepository({ findOne: () => @@ -105,9 +107,10 @@ describe("LineItemService", () => { }) it("successfully create a line item giftcard", async () => { - const line = await await lineItemService.generate( + const line = await lineItemService.generate( IdMap.getId("test-giftcard"), - IdMap.getId("test-region") + IdMap.getId("test-region"), + 1 ) await lineItemService.create({ @@ -115,8 +118,8 @@ describe("LineItemService", () => { cart_id: IdMap.getId("test-cart"), }) - expect(lineItemRepository.create).toHaveBeenCalledTimes(1) - expect(lineItemRepository.create).toHaveBeenCalledWith({ + expect(lineItemRepository.create).toHaveBeenCalledTimes(2) + expect(lineItemRepository.create).toHaveBeenNthCalledWith(2, expect.objectContaining({ allow_discounts: false, variant_id: IdMap.getId("test-giftcard"), cart_id: IdMap.getId("test-cart"), @@ -128,7 +131,7 @@ describe("LineItemService", () => { is_giftcard: true, should_merge: true, metadata: {}, - }) + })) }) }) diff --git a/packages/medusa/src/services/line-item.js b/packages/medusa/src/services/line-item.js deleted file mode 100644 index e48f0892f4..0000000000 --- a/packages/medusa/src/services/line-item.js +++ /dev/null @@ -1,282 +0,0 @@ -import { MedusaError } from "medusa-core-utils" -import { BaseService } from "medusa-interfaces" - -/** - * Provides layer to manipulate line items. - * @extends BaseService - */ -class LineItemService extends BaseService { - constructor({ - manager, - lineItemRepository, - lineItemTaxLineRepository, - productVariantService, - productService, - regionService, - cartRepository, - lineItemAdjustmentService, - }) { - super() - - /** @private @const {EntityManager} */ - this.manager_ = manager - - /** @private @const {LineItemRepository} */ - this.lineItemRepository_ = lineItemRepository - - /** @private @const {typeof LineItemTaxLineRepository} */ - this.itemTaxLineRepo_ = lineItemTaxLineRepository - - /** @private @const {ProductVariantService} */ - this.productVariantService_ = productVariantService - - /** @private @const {ProductService} */ - this.productService_ = productService - - /** @private @const {RegionService} */ - this.regionService_ = regionService - - /** @private @const {CartRepository} */ - this.cartRepository_ = cartRepository - - this.lineItemAdjustmentService_ = lineItemAdjustmentService - } - - withTransaction(transactionManager) { - if (!transactionManager) { - return this - } - - const cloned = new LineItemService({ - manager: transactionManager, - lineItemRepository: this.lineItemRepository_, - lineItemTaxLineRepository: this.itemTaxLineRepo_, - productVariantService: this.productVariantService_, - productService: this.productService_, - regionService: this.regionService_, - cartRepository: this.cartRepository_, - lineItemAdjustmentService: this.lineItemAdjustmentService_, - }) - - cloned.transactionManager_ = transactionManager - - return cloned - } - - async list( - selector, - config = { skip: 0, take: 50, order: { created_at: "DESC" } } - ) { - const liRepo = this.manager_.getCustomRepository(this.lineItemRepository_) - const query = this.buildQuery_(selector, config) - return liRepo.find(query) - } - - /** - * Retrieves a line item by its id. - * @param {string} id - the id of the line item to retrieve - * @param {object} config - the config to be used at query building - * @return {LineItem} the line item - */ - async retrieve(id, config = {}) { - const lineItemRepository = this.manager_.getCustomRepository( - this.lineItemRepository_ - ) - - const validatedId = this.validateId_(id) - const query = this.buildQuery_({ id: validatedId }, config) - - const lineItem = await lineItemRepository.findOne(query) - - if (!lineItem) { - throw new MedusaError( - MedusaError.Types.NOT_FOUND, - `Line item with ${id} was not found` - ) - } - - return lineItem - } - - /** - * Creates return line items for a given cart based on the return items in a - * return. - * @param {string} returnId - the id to generate return items from. - * @param {string} cartId - the cart to assign the return line items to. - * @return {Promise} the created line items - */ - async createReturnLines(returnId, cartId) { - const lineItemRepo = this.manager_.getCustomRepository( - this.lineItemRepository_ - ) - - const itemTaxLineRepo = this.manager_.getCustomRepository( - this.itemTaxLineRepo_ - ) - - const items = await lineItemRepo.findByReturn(returnId) - - const toCreate = items.map((i) => - lineItemRepo.create({ - cart_id: cartId, - thumbnail: i.thumbnail, - is_return: true, - title: i.title, - variant_id: i.variant_id, - unit_price: -1 * i.unit_price, - quantity: i.return_item.quantity, - allow_discounts: i.allow_discounts, - tax_lines: i.tax_lines.map((tl) => { - return itemTaxLineRepo.create({ - name: tl.name, - code: tl.code, - rate: tl.rate, - metadata: tl.metadata, - }) - }), - metadata: i.metadata, - adjustments: i.adjustments.map((adjustment) => { - return { - amount: -1 * adjustment.amount, - description: adjustment.description, - discount_id: adjustment.discount_id, - metadata: adjustment.metadata, - } - }), - }) - ) - - return await lineItemRepo.save(toCreate) - } - - async generate(variantId, regionId, quantity, context = {}) { - return this.atomicPhase_(async (manager) => { - const variant = await this.productVariantService_ - .withTransaction(manager) - .retrieve(variantId, { - relations: ["product"], - include_discount_prices: true, - }) - - const region = await this.regionService_ - .withTransaction(manager) - .retrieve(regionId) - - let price - let shouldMerge = true - - if (context.unit_price !== undefined && context.unit_price !== null) { - // if custom unit_price, we ensure positive values - // and we choose to not merge the items - shouldMerge = false - if (context.unit_price < 0) { - price = 0 - } else { - price = context.unit_price - } - } else { - price = await this.productVariantService_ - .withTransaction(manager) - .getRegionPrice(variant.id, { - regionId: region.id, - quantity: quantity, - customer_id: context.customer_id, - include_discount_prices: true, - }) - } - - const toCreate = { - unit_price: price, - title: variant.product.title, - description: variant.title, - thumbnail: variant.product.thumbnail, - variant_id: variant.id, - quantity: quantity || 1, - allow_discounts: variant.product.discountable, - is_giftcard: variant.product.is_giftcard, - metadata: context?.metadata || {}, - should_merge: shouldMerge, - } - - if (context.cart) { - const adjustments = await this.lineItemAdjustmentService_ - .withTransaction(manager) - .generateAdjustments(context.cart, toCreate, { variant }) - toCreate.adjustments = adjustments - } - - return toCreate - }) - } - - /** - * Create a line item - * @param {LineItem} lineItem - the line item object to create - * @return {LineItem} the created line item - */ - async create(lineItem) { - return this.atomicPhase_(async (manager) => { - const lineItemRepository = manager.getCustomRepository( - this.lineItemRepository_ - ) - - const created = await lineItemRepository.create(lineItem) - const result = await lineItemRepository.save(created) - return result - }) - } - - /** - * Updates a line item - * @param {string} id - the id of the line item to update - * @param {object} update - the properties to update on line item - * @return {LineItem} the update line item - */ - async update(id, update) { - return this.atomicPhase_(async (manager) => { - const lineItemRepository = manager.getCustomRepository( - this.lineItemRepository_ - ) - - const lineItem = await this.retrieve(id) - - const { metadata, ...rest } = update - - if (metadata) { - lineItem.metadata = this.setMetadata_(lineItem, metadata) - } - - for (const [key, value] of Object.entries(rest)) { - lineItem[key] = value - } - - const result = await lineItemRepository.save(lineItem) - return result - }) - } - - /** - * Deletes a line item. - * @param {string} id - the id of the line item to delete - * @return {Promise} the result of the delete operation - */ - async delete(id) { - return this.atomicPhase_(async (manager) => { - const lineItemRepository = manager.getCustomRepository( - this.lineItemRepository_ - ) - - const lineItem = await lineItemRepository.findOne({ where: { id } }) - - if (!lineItem) { - return Promise.resolve() - } - - await lineItemRepository.remove(lineItem) - - return Promise.resolve() - }) - } -} - -export default LineItemService diff --git a/packages/medusa/src/services/line-item.ts b/packages/medusa/src/services/line-item.ts index 89fd8befcd..55e55a802c 100644 --- a/packages/medusa/src/services/line-item.ts +++ b/packages/medusa/src/services/line-item.ts @@ -236,10 +236,10 @@ class LineItemService extends BaseService { should_merge: shouldMerge, } - const lineLitemRepo = transactionManager.getCustomRepository( + const lineItemRepo = transactionManager.getCustomRepository( this.lineItemRepository_ ) - const lineItem = lineLitemRepo.create(rawLineItem) + const lineItem = lineItemRepo.create(rawLineItem) if (context.cart) { const adjustments = await this.lineItemAdjustmentService_ From 9b5354502f69ca438ca16c4fbe92b67493842450 Mon Sep 17 00:00:00 2001 From: adrien2p Date: Tue, 10 May 2022 11:09:52 +0200 Subject: [PATCH 23/92] test(medusa): Fix test pipelines --- .../store/__snapshots__/swaps.js.snap | 4 +-- .../api/__tests__/store/swaps.js | 15 ++------ .../medusa/src/services/__tests__/cart.js | 35 ++----------------- packages/medusa/src/services/cart.ts | 3 +- 4 files changed, 8 insertions(+), 49 deletions(-) diff --git a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap index 5605ac2735..654c95ac5e 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/swaps.js.snap @@ -98,7 +98,7 @@ Object { "payment_authorized_at": null, "payment_id": null, "region_id": "test-region", - "shipping_address_id": StringMatching /\\^addr_\\*/, + "shipping_address_id": "test-shipping-address", "type": "swap", "updated_at": Any, }, @@ -274,7 +274,7 @@ Object { "payment_authorized_at": null, "payment_id": null, "region_id": "test-region", - "shipping_address_id": StringMatching /\\^addr_\\*/, + "shipping_address_id": "test-shipping-address", "type": "swap", "updated_at": Any, }, diff --git a/integration-tests/api/__tests__/store/swaps.js b/integration-tests/api/__tests__/store/swaps.js index 243d5e1e96..1065aef2c6 100644 --- a/integration-tests/api/__tests__/store/swaps.js +++ b/integration-tests/api/__tests__/store/swaps.js @@ -1,18 +1,7 @@ const path = require("path") const { - Region, - Order, - Customer, ShippingProfile, - Product, - ProductVariant, - MoneyAmount, - LineItem, - Payment, - Cart, - ShippingMethod, ShippingOption, - Swap, } = require("@medusajs/medusa") const setupServer = require("../../../helpers/setup-server") @@ -137,7 +126,7 @@ describe("/store/carts", () => { type: "swap", created_at: expect.any(String), updated_at: expect.any(String), - shipping_address_id: expect.stringMatching(/^addr_*/), + shipping_address_id: "test-shipping-address", metadata: { swap_id: expect.stringMatching(/^swap_*/), }, @@ -221,7 +210,7 @@ describe("/store/carts", () => { cart: { id: expect.stringMatching(/^cart_*/), billing_address_id: "test-billing-address", - shipping_address_id: expect.stringMatching(/^addr_*/), + shipping_address_id: "test-shipping-address", type: "swap", created_at: expect.any(String), updated_at: expect.any(String), diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 0dd999c58f..92c4455c64 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -1,9 +1,11 @@ import _ from "lodash" +import { getManager } from "typeorm" import { MedusaError } from "medusa-core-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import CartService from "../cart" import { InventoryServiceMock } from "../__mocks__/inventory" import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment" +import { CartRepository } from "../../repositories/cart" const eventBusService = { emit: jest.fn(), @@ -276,39 +278,6 @@ describe("CartService", () => { expect(cartRepository.save).toHaveBeenCalledTimes(1) }) - it("successfully creates a cart with a shipping address id but no shippings_address", async () => { - await cartService.create({ - region_id: IdMap.getId("testRegion"), - email: "email@test.com", - shipping_address_id: "test_shipping_address", - }) - - expect(eventBusService.emit).toHaveBeenCalledTimes(1) - expect(eventBusService.emit).toHaveBeenCalledWith( - "cart.created", - expect.any(Object) - ) - - expect(cartRepository.create).toHaveBeenCalledTimes(1) - expect(cartRepository.create).toHaveBeenCalledWith(expect.objectContaining({ - region_id: IdMap.getId("testRegion"), - shipping_address: { - id: "test_shipping_address", - first_name: "LeBron", - last_name: "James", - address_1: "Dunk St", - city: "Dunkville", - province: "CA", - postal_code: "12345", - country_code: "us", - }, - customer_id: IdMap.getId("customer"), - email: "email@test.com" - })) - - expect(cartRepository.save).toHaveBeenCalledTimes(1) - }) - it("creates a cart with a prefilled shipping address but a country not part of the region", async () => { const res = cartService.create({ region_id: IdMap.getId("testRegion"), diff --git a/packages/medusa/src/services/cart.ts b/packages/medusa/src/services/cart.ts index c2cf84154f..ae1755f6cb 100644 --- a/packages/medusa/src/services/cart.ts +++ b/packages/medusa/src/services/cart.ts @@ -37,6 +37,7 @@ import InventoryService from "./inventory" import CustomShippingOptionService from "./custom-shipping-option" import LineItemAdjustmentService from "./line-item-adjustment" import { LineItemRepository } from "../repositories/line-item" +import { raw } from "express" type InjectedDependencies = { manager: EntityManager @@ -417,7 +418,7 @@ class CartService extends BaseService { "Shipping country not in region" ) } - rawCart.shipping_address = addr + rawCart.shipping_address_id = data.shipping_address_id } } From d9bc6b4c4117a994ad1f5300076daabfdb26c706 Mon Sep 17 00:00:00 2001 From: adrien2p Date: Tue, 10 May 2022 14:13:01 +0200 Subject: [PATCH 24/92] style(medusa): Remove unused dependencies --- packages/medusa/src/services/__tests__/cart.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 92c4455c64..07bab79711 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -1,11 +1,9 @@ import _ from "lodash" -import { getManager } from "typeorm" import { MedusaError } from "medusa-core-utils" import { IdMap, MockManager, MockRepository } from "medusa-test-utils" import CartService from "../cart" import { InventoryServiceMock } from "../__mocks__/inventory" import { LineItemAdjustmentServiceMock } from "../__mocks__/line-item-adjustment" -import { CartRepository } from "../../repositories/cart" const eventBusService = { emit: jest.fn(), From 1c3c62e9668fa955e39b2571ac4c7ed919898c6e Mon Sep 17 00:00:00 2001 From: adrien2p Date: Tue, 10 May 2022 14:14:59 +0200 Subject: [PATCH 25/92] test(medusa): Fix integration --- .../medusa-plugin-sendgrid/__snapshots__/index.js.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap index b414a3f7a1..e80d777ab5 100644 --- a/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap +++ b/integration-tests/plugins/__tests__/medusa-plugin-sendgrid/__snapshots__/index.js.snap @@ -2389,9 +2389,9 @@ Object { "created_at": Any, "customer_id": null, "deleted_at": null, - "first_name": null, + "first_name": "Chyna", "id": Any, - "last_name": null, + "last_name": "Osinski", "metadata": null, "phone": "12353245", "postal_code": "1234", From 1c5647f69e41b622e71cac22255621fcae75b48c Mon Sep 17 00:00:00 2001 From: adrien2p Date: Tue, 10 May 2022 16:28:10 +0200 Subject: [PATCH 26/92] style(medusa): Cleanup some tests case naming --- packages/medusa/src/services/__tests__/cart.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/medusa/src/services/__tests__/cart.js b/packages/medusa/src/services/__tests__/cart.js index 07bab79711..0fefc85592 100644 --- a/packages/medusa/src/services/__tests__/cart.js +++ b/packages/medusa/src/services/__tests__/cart.js @@ -276,7 +276,7 @@ describe("CartService", () => { expect(cartRepository.save).toHaveBeenCalledTimes(1) }) - it("creates a cart with a prefilled shipping address but a country not part of the region", async () => { + it("should throw shipping country not in region", async () => { const res = cartService.create({ region_id: IdMap.getId("testRegion"), shipping_address: { @@ -293,7 +293,7 @@ describe("CartService", () => { await expect(res).rejects.toThrow("Shipping country not in region") }) - it("creates a cart with a prefilled shipping address", async () => { + it("a cart with a prefilled shipping address", async () => { await cartService.create({ region_id: IdMap.getId("testRegion"), shipping_address: { From 162777364f6966abdaca4fb3a3f90cd2660ce8a4 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Tue, 10 May 2022 17:44:34 +0300 Subject: [PATCH 27/92] use relative link --- docs/content/advanced/backend/payment/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/advanced/backend/payment/overview.md b/docs/content/advanced/backend/payment/overview.md index c65119176a..a34c19e4e3 100644 --- a/docs/content/advanced/backend/payment/overview.md +++ b/docs/content/advanced/backend/payment/overview.md @@ -127,4 +127,4 @@ This prevents any payment issues from occurring with the customers and allows fo ## What’s Next 🚀 - [Check out how the checkout flow is implemented on the frontend.](./frontend-payment-flow-in-checkout.md) -- Check out payment plugins like [Stripe](../../../add-plugins/stripe.md), [Paypal](../../../add-plugins/paypal.md), and [Klarna](../../../add-plugins/klarna.md). +- Check out payment plugins like [Stripe](../../../add-plugins/stripe.md), [Paypal](/add-plugins/paypal), and [Klarna](../../../add-plugins/klarna.md). From 5c51b724fedac7f6adbcefffe599a49cb1d6a80f Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Tue, 10 May 2022 17:47:16 +0300 Subject: [PATCH 28/92] Revert "use relative link" This reverts commit 162777364f6966abdaca4fb3a3f90cd2660ce8a4. --- docs/content/advanced/backend/payment/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/advanced/backend/payment/overview.md b/docs/content/advanced/backend/payment/overview.md index a34c19e4e3..c65119176a 100644 --- a/docs/content/advanced/backend/payment/overview.md +++ b/docs/content/advanced/backend/payment/overview.md @@ -127,4 +127,4 @@ This prevents any payment issues from occurring with the customers and allows fo ## What’s Next 🚀 - [Check out how the checkout flow is implemented on the frontend.](./frontend-payment-flow-in-checkout.md) -- Check out payment plugins like [Stripe](../../../add-plugins/stripe.md), [Paypal](/add-plugins/paypal), and [Klarna](../../../add-plugins/klarna.md). +- Check out payment plugins like [Stripe](../../../add-plugins/stripe.md), [Paypal](../../../add-plugins/paypal.md), and [Klarna](../../../add-plugins/klarna.md). From 327614e126d57b1c53ca95b2298c8e4ec1dd34fb Mon Sep 17 00:00:00 2001 From: Adrien de Peretti Date: Tue, 10 May 2022 17:34:41 +0200 Subject: [PATCH 29/92] feat(medusa-payment-stripe): Ability to add payment description and improve status resolution (#1404) --- .../src/__mocks__/cart.js | 2 +- .../src/__mocks__/stripe.js | 2 + .../src/services/__tests__/stripe-provider.js | 2 + .../src/services/stripe-bancontact.js | 1 + .../src/services/stripe-giropay.js | 1 + .../src/services/stripe-ideal.js | 1 + .../src/services/stripe-provider.js | 51 +++++++------------ 7 files changed, 25 insertions(+), 35 deletions(-) diff --git a/packages/medusa-payment-stripe/src/__mocks__/cart.js b/packages/medusa-payment-stripe/src/__mocks__/cart.js index 361f80386d..062dc1003b 100644 --- a/packages/medusa-payment-stripe/src/__mocks__/cart.js +++ b/packages/medusa-payment-stripe/src/__mocks__/cart.js @@ -61,7 +61,6 @@ export const carts = { id: IdMap.getId("product"), }, quantity: 1, - quantity: 10, }, ], shipping_methods: [ @@ -96,6 +95,7 @@ export const carts = { billing_address: {}, discounts: [], customer_id: IdMap.getId("lebron"), + context: {} }, frCartNoStripeCustomer: { id: IdMap.getId("fr-cart-no-customer"), diff --git a/packages/medusa-payment-stripe/src/__mocks__/stripe.js b/packages/medusa-payment-stripe/src/__mocks__/stripe.js index 7aa040b8f6..23b250a650 100644 --- a/packages/medusa-payment-stripe/src/__mocks__/stripe.js +++ b/packages/medusa-payment-stripe/src/__mocks__/stripe.js @@ -22,6 +22,7 @@ export const StripeMock = { id: "pi_lebron", amount: 100, customer: "cus_123456789_new", + description: data?.description, }) } if (data.customer === "cus_lebron") { @@ -29,6 +30,7 @@ export const StripeMock = { id: "pi_lebron", amount: 100, customer: "cus_lebron", + description: data?.description, }) } }), diff --git a/packages/medusa-payment-stripe/src/services/__tests__/stripe-provider.js b/packages/medusa-payment-stripe/src/services/__tests__/stripe-provider.js index f4ec7ac711..5bb2874d86 100644 --- a/packages/medusa-payment-stripe/src/services/__tests__/stripe-provider.js +++ b/packages/medusa-payment-stripe/src/services/__tests__/stripe-provider.js @@ -70,11 +70,13 @@ describe("StripeProviderService", () => { it("returns created stripe payment intent for cart with no customer", async () => { carts.frCart.customer_id = "" + carts.frCart.context.payment_description = 'some description' result = await stripeProviderService.createPayment(carts.frCart) expect(result).toEqual({ id: "pi_lebron", customer: "cus_lebron", amount: 100, + description: 'some description', }) }) }) diff --git a/packages/medusa-payment-stripe/src/services/stripe-bancontact.js b/packages/medusa-payment-stripe/src/services/stripe-bancontact.js index 3b731e74d4..8ed87606e9 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-bancontact.js +++ b/packages/medusa-payment-stripe/src/services/stripe-bancontact.js @@ -90,6 +90,7 @@ class BancontactProviderService extends PaymentService { const intentRequest = { amount: Math.round(amount), + description: cart?.context?.payment_description ?? this.options?.payment_description, currency: currency_code, payment_method_types: ["bancontact"], capture_method: "automatic", diff --git a/packages/medusa-payment-stripe/src/services/stripe-giropay.js b/packages/medusa-payment-stripe/src/services/stripe-giropay.js index 9df35469df..05743831e9 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-giropay.js +++ b/packages/medusa-payment-stripe/src/services/stripe-giropay.js @@ -90,6 +90,7 @@ class GiropayProviderService extends PaymentService { const intentRequest = { amount: Math.round(amount), + description: cart?.context?.payment_description ?? this.options?.payment_description, currency: currency_code, payment_method_types: ["giropay"], capture_method: "automatic", diff --git a/packages/medusa-payment-stripe/src/services/stripe-ideal.js b/packages/medusa-payment-stripe/src/services/stripe-ideal.js index e5bee5a99f..934de954aa 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-ideal.js +++ b/packages/medusa-payment-stripe/src/services/stripe-ideal.js @@ -90,6 +90,7 @@ class IdealProviderService extends PaymentService { const intentRequest = { amount: Math.round(amount), + description: cart?.context?.payment_description ?? this.options?.payment_description, currency: currency_code, payment_method_types: ["ideal"], capture_method: "automatic", diff --git a/packages/medusa-payment-stripe/src/services/stripe-provider.js b/packages/medusa-payment-stripe/src/services/stripe-provider.js index 781dabbcf4..c0c406a1fb 100644 --- a/packages/medusa-payment-stripe/src/services/stripe-provider.js +++ b/packages/medusa-payment-stripe/src/services/stripe-provider.js @@ -1,6 +1,6 @@ -import _ from "lodash" import Stripe from "stripe" import { PaymentService } from "medusa-interfaces" +import { PaymentSessionStatus } from '@medusajs/medusa/dist' class StripeProviderService extends PaymentService { static identifier = "stripe" @@ -42,37 +42,21 @@ class StripeProviderService extends PaymentService { const { id } = paymentData const paymentIntent = await this.stripe_.paymentIntents.retrieve(id) - let status = "pending" - - if (paymentIntent.status === "requires_payment_method") { - return status + switch (paymentIntent.status) { + case "requires_payment_method": + case "requires_confirmation": + case "processing": + return PaymentSessionStatus.PENDING + case "requires_action": + return PaymentSessionStatus.REQUIRES_MORE + case "canceled": + return PaymentSessionStatus.CANCELED + case "requires_capture": + case "succeeded": + return PaymentSessionStatus.AUTHORIZED + default: + return PaymentSessionStatus.PENDING } - - if (paymentIntent.status === "requires_confirmation") { - return status - } - - if (paymentIntent.status === "processing") { - return status - } - - if (paymentIntent.status === "requires_action") { - status = "requires_more" - } - - if (paymentIntent.status === "canceled") { - status = "canceled" - } - - if (paymentIntent.status === "requires_capture") { - status = "authorized" - } - - if (paymentIntent.status === "succeeded") { - status = "authorized" - } - - return status } /** @@ -141,6 +125,7 @@ class StripeProviderService extends PaymentService { const amount = await this.totalsService_.getTotal(cart) const intentRequest = { + description: cart?.context?.payment_description ?? this.options?.payment_description, amount: Math.round(amount), currency: currency_code, setup_future_usage: "on_session", @@ -169,11 +154,9 @@ class StripeProviderService extends PaymentService { intentRequest.customer = stripeCustomer.id } - const paymentIntent = await this.stripe_.paymentIntents.create( + return await this.stripe_.paymentIntents.create( intentRequest ) - - return paymentIntent } /** From 863d80396a9ec1eb535ed1194a52b790bc573406 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Tue, 10 May 2022 19:00:54 +0300 Subject: [PATCH 30/92] docs: Migration and Upgrade Guides (#1487) --- docs/content/advanced/backend/migrations.md | 97 +++++++++++++++++++ .../advanced/backend/upgrade-guides/1-3-1.md | 51 ++++++++++ www/docs/sidebars.js | 17 ++++ 3 files changed, 165 insertions(+) create mode 100644 docs/content/advanced/backend/migrations.md create mode 100644 docs/content/advanced/backend/upgrade-guides/1-3-1.md diff --git a/docs/content/advanced/backend/migrations.md b/docs/content/advanced/backend/migrations.md new file mode 100644 index 0000000000..e5949b7176 --- /dev/null +++ b/docs/content/advanced/backend/migrations.md @@ -0,0 +1,97 @@ +# Migrations + +In this document, you’ll learn about what Migrations are, their purpose, how you can run them, and how you can create your own Migrations. + +:::note + +Medusa’s Migrations do not work with SQLite databases. They are intended to be used with PostgreSQL databases, which is the recommended Database for your Medusa production server. + +::: + +## Overview + +Migrations are scripts that are used to make additions or changes to your database schema. In Medusa, they are essential for both when you first install your server and for subsequent server upgrades later on. + +When you first create your Medusa server, the database schema used must have all the tables necessary for the server to run. + +When a new Medusa version introduces changes to the database schema, you'll have to run migrations to apply them to your own database. + +:::tip + +Migrations are used to apply changes to the database schema. However, there are some version updates of Medusa that require updating the data in your database to fit the new schema. Those are specific to each version and you should check out the version under Upgrade Guides for details on the steps. + +::: + +## How to Run Migrations + +Migrations in Medusa can be done in one of two ways: + +### Migrate Command + +Using the Medusa CLI tool, you can run migrations with the following command: + +```bash +medusa migrations run +``` + +This will check for any migrations that contain changes to your database schema that aren't applied yet and run them on your server. + +### Seed Command + +Seeding is the process of filling your database with data that is either essential or for testing and demo purposes. In Medusa, the `seed` command will run the migrations to your database if necessary before it seeds your database with dummy data. + +You can use the following command to seed your database: + +```bash npm2yarn +npm run seed +``` + +This will use the underlying `seed` command provided by Medusa's CLI to seed your database with data from the file `data/seed.json` on your Medusa server. + +## How to Create Migrations + +In this section, you’ll learn how to create your own migrations using [Typeorm](https://typeorm.io). This will allow you to modify Medusa’s predefined tables or create your own tables. + +### Create Migration + +To create a migration that makes changes to your Medusa schema, run the following command: + +```bash +npx typeorm migration:create -n src/path/to/UserChanged +``` + +:::tip + +The migration file should be inside the src directory to make sure it is built when the build command is run next. + +::: + +This will create the migration file in the path you specify. You can use this without the need to install Typeorm's CLI tool. You can then go ahead and make changes to it as necessary. + +:::tip + +You can learn more about writing migrations in [Typeorm’s Documentation](https://typeorm.io/migrations). + +::: + +### Build Files + +Before you can run the migrations you need to run the build command to transpile the TypeScript files to JavaScript files: + +```bash npm2yarn +npm run build +``` + +### Run Migration + +The last step is to run the migration with the command detailed earlier + +```bash +medusa migrations run +``` + +If you check your database now you should see that the change defined by the migration has been applied successfully. + +## What’s Next 🚀 + +- Learn more about [setting up your development server](../../tutorial/0-set-up-your-development-environment.md). diff --git a/docs/content/advanced/backend/upgrade-guides/1-3-1.md b/docs/content/advanced/backend/upgrade-guides/1-3-1.md new file mode 100644 index 0000000000..818b0c6175 --- /dev/null +++ b/docs/content/advanced/backend/upgrade-guides/1-3-1.md @@ -0,0 +1,51 @@ +# v1.3.1 + +Version 1.3.1 of Medusa introduces new features including the addition of Line Item Adjustments and a more advanced Promotions API. The changes do not affect the public APIs and require only running necessary data migrations. + +## Prerequisites + +Both the actions required for this update need you to set the following environment variables: + +```bash +TYPEORM_CONNECTION=postgres +TYPEORM_URL= +TYPEORM_LOGGING=true +TYPEORM_ENTITIES=./node_modules/@medusajs/medusa/dist/models/*.js +TYPEORM_MIGRATIONS=./node_modules/@medusajs/medusa/dist/migrations/*.js +``` + +These environment variables are used in the data migration scripts in this upgrade. Make sure to replace `` with your PostgreSQL database URL. + +## Line Item Adjustments + +This new version of Medusa allows store operators to adjust line items in an order or a swap which provides more customization capabilities. + +It introduces a new model `LineItemAdjustment` which gives more flexibility to adjust the pricing of line items in a cart, order, or swap. A discount can be added, removed, or modified and the price will reflect on the total calculation of the cart, order, or swap. + +This also introduces an optimization to the calculation of totals, as it is no longer necessary to calculate the discounts every time the totals are retrieved. + +### Actions Required + +This new version adds a new data migration script that will go through your list of existing orders and add line item adjustments for each of the line items in the order. + +For that reason, it’s essential to run the data migration script after upgrading your server and before starting your Medusa server: + +```bash +node ./node_modules/@medusajs/medusa/dist/scripts/line-item-adjustment-migration.js +``` + +## Advanced Discount Conditions + +This new version of Medusa holds advanced promotions functionalities to provide store operators with even more customization capabilities when creating discounts. You can now add even more conditions to your discounts to make them specific for a set of products, collections, customer groups, and more. + +This change required creating a new model `DiscountCondition` which belongs to `DiscountRule` and includes a few relationships with other models to make the aforementioned feature possible. + +### Actions Required + +To ensure your old discount rules play well with the new Promotions API and schema, this version includes a migration script that will go through your existing discount rules, create discount conditions for these rules, and move the former direct relationship between discount rules and products to become between discount conditions and products. + +For that reason, it’s essential to run the data migration script after upgrading your server and before starting your Medusa server: + +```bash +node ./node_modules/@medusajs/medusa/dist/scripts/discount-rule-migration.js +``` diff --git a/www/docs/sidebars.js b/www/docs/sidebars.js index 2178db48cb..7ede695603 100644 --- a/www/docs/sidebars.js +++ b/www/docs/sidebars.js @@ -204,6 +204,23 @@ module.exports = { type: "doc", id: "guides/carts-in-medusa", }, + { + type: "doc", + id: "advanced/backend/migrations", + label: "Migrations" + }, + { + type: "category", + label: 'Upgrade Guides', + collapsed: true, + items: [ + { + type: "doc", + id: "advanced/backend/upgrade-guides/1-3-1", + label: "v1.3.1" + }, + ] + }, ] } ] From 03c5617896d08b354932a470cadd9d36508f9d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Frane=20Poli=C4=87?= <16856471+fPolic@users.noreply.github.com> Date: Wed, 11 May 2022 07:43:19 +0200 Subject: [PATCH 31/92] fix(medusa): allow price list `prices` update when `region_id` is provided (#1472) --- .../admin/__snapshots__/price-list.js.snap | 75 ++++++++++ .../api/__tests__/admin/price-list.js | 138 +++++++++++++++++- .../api/helpers/price-list-seeder.js | 32 +++- packages/medusa/src/services/price-list.ts | 38 ++++- 4 files changed, 279 insertions(+), 4 deletions(-) diff --git a/integration-tests/api/__tests__/admin/__snapshots__/price-list.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/price-list.js.snap index c2685e78ac..8c3f703c5f 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/price-list.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/price-list.js.snap @@ -149,6 +149,37 @@ Object { } `; +exports[`/admin/price-lists POST /admin/price-lists/:id updates price list prices (inser a new MA for a specific region) 1`] = ` +Array [ + Object { + "amount": 101, + "created_at": Any, + "currency_code": "eur", + "deleted_at": null, + "id": Any, + "max_quantity": null, + "min_quantity": null, + "price_list_id": "pl_with_some_ma", + "region_id": "region-pl", + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 1001, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": "ma_test_4", + "max_quantity": null, + "min_quantity": null, + "price_list_id": "pl_with_some_ma", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, +] +`; + exports[`/admin/price-lists POST /admin/price-lists/:id updates the amount and currency of a price in the price list 1`] = ` Object { "amount": 250, @@ -291,3 +322,47 @@ Array [ }, ] `; + +exports[`/admin/price-lists POST /admin/price-lists/:id/prices/batch Adds a batch of new prices where a MA record have a \`region_id\` instead of \`currency_code\` 1`] = ` +Array [ + Object { + "amount": 70, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": "ma_test_4", + "max_quantity": null, + "min_quantity": null, + "price_list_id": "pl_with_some_ma", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 100, + "created_at": Any, + "currency_code": "eur", + "deleted_at": null, + "id": Any, + "max_quantity": null, + "min_quantity": null, + "price_list_id": "pl_with_some_ma", + "region_id": "region-pl", + "updated_at": Any, + "variant_id": "test-variant", + }, + Object { + "amount": 200, + "created_at": Any, + "currency_code": "usd", + "deleted_at": null, + "id": Any, + "max_quantity": null, + "min_quantity": null, + "price_list_id": "pl_with_some_ma", + "region_id": null, + "updated_at": Any, + "variant_id": "test-variant", + }, +] +`; diff --git a/integration-tests/api/__tests__/admin/price-list.js b/integration-tests/api/__tests__/admin/price-list.js index b1c39b9819..7291a4f956 100644 --- a/integration-tests/api/__tests__/admin/price-list.js +++ b/integration-tests/api/__tests__/admin/price-list.js @@ -509,6 +509,68 @@ describe("/admin/price-lists", () => { updated_at: expect.any(String), }) }) + + it("updates price list prices (inser a new MA for a specific region)", async () => { + const api = useApi() + + const payload = { + prices: [ + // update MA + { + id: "ma_test_4", + amount: 1001, + currency_code: "usd", + variant_id: "test-variant", + }, + // create MA + { + amount: 101, + variant_id: "test-variant", + region_id: "region-pl", + }, + ], + } + + const response = await api + .post("/admin/price-lists/pl_with_some_ma", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + + expect(response.data.price_list.prices.length).toEqual(2) + expect(response.data.price_list.prices).toMatchSnapshot([ + { + id: expect.any(String), + currency_code: "eur", + amount: 101, + min_quantity: null, + max_quantity: null, + price_list_id: "pl_with_some_ma", + variant_id: "test-variant", + region_id: "region-pl", + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + { + id: "ma_test_4", + currency_code: "usd", + amount: 1001, + price_list_id: "pl_with_some_ma", + variant_id: "test-variant", + region_id: null, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + ]) + }) }) describe("POST /admin/price-lists/:id/prices/batch", () => { @@ -573,7 +635,9 @@ describe("/admin/price-lists", () => { expect(response.status).toEqual(200) expect(response.data.price_list.prices.length).toEqual(6) - expect(response.data.price_list.prices).toMatchSnapshot([ + expect( + response.data.price_list.prices.sort((a, b) => b.amount - a.amount) + ).toMatchSnapshot([ { id: expect.any(String), price_list_id: "pl_no_customer_groups", @@ -725,6 +789,78 @@ describe("/admin/price-lists", () => { }, ]) }) + + it("Adds a batch of new prices where a MA record have a `region_id` instead of `currency_code`", async () => { + const api = useApi() + + const payload = { + prices: [ + { + amount: 100, + variant_id: "test-variant", + region_id: "region-pl", + }, + { + amount: 200, + variant_id: "test-variant", + currency_code: "usd", + }, + ], + } + + const response = await api + .post("/admin/price-lists/pl_with_some_ma/prices/batch", payload, { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + + expect(response.data.price_list.prices.length).toEqual(3) // initially this PL has 1 MA record + expect(response.data.price_list.prices).toMatchSnapshot([ + { + id: "ma_test_4", + currency_code: "usd", + amount: 70, + price_list_id: "pl_with_some_ma", + variant_id: "test-variant", + region_id: null, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + { + id: expect.any(String), + currency_code: "eur", + amount: 100, + min_quantity: null, + max_quantity: null, + price_list_id: "pl_with_some_ma", + variant_id: "test-variant", + region_id: "region-pl", + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + { + id: expect.any(String), + currency_code: "usd", + amount: 200, + min_quantity: null, + max_quantity: null, + price_list_id: "pl_with_some_ma", + variant_id: "test-variant", + region_id: null, + created_at: expect.any(String), + updated_at: expect.any(String), + deleted_at: null, + }, + ]) + }) }) describe("DELETE /admin/price-lists/:id", () => { diff --git a/integration-tests/api/helpers/price-list-seeder.js b/integration-tests/api/helpers/price-list-seeder.js index 984db87a88..f481176cc3 100644 --- a/integration-tests/api/helpers/price-list-seeder.js +++ b/integration-tests/api/helpers/price-list-seeder.js @@ -1,4 +1,4 @@ -const { PriceList, MoneyAmount } = require("@medusajs/medusa") +const { Region, PriceList, MoneyAmount } = require("@medusajs/medusa") module.exports = async (connection, data = {}) => { const manager = connection.manager @@ -15,6 +15,13 @@ module.exports = async (connection, data = {}) => { await manager.save(priceListNoCustomerGroups) + await manager.insert(Region, { + id: "region-pl", + name: "Test Region", + currency_code: "eur", + tax_rate: 0, + }) + const moneyAmount1 = await manager.create(MoneyAmount, { id: "ma_test_1", amount: 100, @@ -50,4 +57,27 @@ module.exports = async (connection, data = {}) => { }) await manager.save(moneyAmount3) + + const moneyAmount4 = await manager.create(MoneyAmount, { + id: "ma_test_4", + amount: 70, + currency_code: "usd", + variant_id: "test-variant", + }) + + await manager.save(moneyAmount4) + + const priceListWithMA = await manager.create(PriceList, { + id: "pl_with_some_ma", + name: "Weeken sale", + description: "Desc. of the list", + type: "sale", + status: "active", + starts_at: "2022-07-01T00:00:00.000Z", + ends_at: "2022-07-31T00:00:00.000Z", + }) + + priceListWithMA.prices = [moneyAmount4] + + await manager.save(priceListWithMA) } diff --git a/packages/medusa/src/services/price-list.ts b/packages/medusa/src/services/price-list.ts index 38bae29080..bb068a3ad4 100644 --- a/packages/medusa/src/services/price-list.ts +++ b/packages/medusa/src/services/price-list.ts @@ -12,14 +12,17 @@ import { CreatePriceListInput, FilterablePriceListProps, PriceListPriceCreateInput, + PriceListPriceUpdateInput, UpdatePriceListInput, } from "../types/price-list" import { formatException } from "../utils/exception-formatter" +import RegionService from "./region" import ProductService from "./product" type PriceListConstructorProps = { manager: EntityManager customerGroupService: CustomerGroupService + regionService: RegionService productService: ProductService priceListRepository: typeof PriceListRepository moneyAmountRepository: typeof MoneyAmountRepository @@ -32,6 +35,7 @@ type PriceListConstructorProps = { class PriceListService extends BaseService { private manager_: EntityManager private customerGroupService_: CustomerGroupService + private regionService_: RegionService private productService_: ProductService private priceListRepo_: typeof PriceListRepository private moneyAmountRepo_: typeof MoneyAmountRepository @@ -39,6 +43,7 @@ class PriceListService extends BaseService { constructor({ manager, customerGroupService, + regionService, productService, priceListRepository, moneyAmountRepository, @@ -47,6 +52,7 @@ class PriceListService extends BaseService { this.manager_ = manager this.customerGroupService_ = customerGroupService this.productService_ = productService + this.regionService_ = regionService this.priceListRepo_ = priceListRepository this.moneyAmountRepo_ = moneyAmountRepository } @@ -60,6 +66,7 @@ class PriceListService extends BaseService { manager: transactionManager, customerGroupService: this.customerGroupService_, productService: this.productService_, + regionService: this.regionService_, priceListRepository: this.priceListRepo_, moneyAmountRepository: this.moneyAmountRepo_, }) @@ -152,7 +159,8 @@ class PriceListService extends BaseService { await priceListRepo.save(priceList) if (prices) { - await moneyAmountRepo.updatePriceListPrices(id, prices) + const prices_ = await this.addCurrencyFromRegion(prices) + await moneyAmountRepo.updatePriceListPrices(id, prices_) } if (customer_groups) { @@ -184,7 +192,8 @@ class PriceListService extends BaseService { const priceList = await this.retrieve(id, { select: ["id"] }) - await moneyAmountRepo.addPriceListPrices(priceList.id, prices, replace) + const prices_ = await this.addCurrencyFromRegion(prices) + await moneyAmountRepo.addPriceListPrices(priceList.id, prices_, replace) const result = await this.retrieve(priceList.id, { relations: ["prices"], @@ -336,6 +345,31 @@ class PriceListService extends BaseService { return [productsWithPrices, count] }) } + + /** + * Add `currency_code` to an MA record if `region_id`is passed. + * @param prices - a list of PriceListPrice(Create/Update)Input records + * @return {Promise} updated `prices` list + */ + protected async addCurrencyFromRegion< + T extends PriceListPriceUpdateInput | PriceListPriceCreateInput + >(prices: T[]): Promise { + const prices_: typeof prices = [] + + for (const p of prices) { + if (p.region_id) { + const region = await this.regionService_ + .withTransaction(this.manager_) + .retrieve(p.region_id) + + p.currency_code = region.currency_code + } + + prices_.push(p) + } + + return prices_ + } } export default PriceListService From e87cdbd3ad4e809992d797bc2351ef9d8e4ab7d9 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Wed, 11 May 2022 12:56:16 +0300 Subject: [PATCH 32/92] replace fulfillment with payment were necessary --- .../backend/payment/how-to-create-payment-provider.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/content/advanced/backend/payment/how-to-create-payment-provider.md b/docs/content/advanced/backend/payment/how-to-create-payment-provider.md index 53295890ce..8037f3374f 100644 --- a/docs/content/advanced/backend/payment/how-to-create-payment-provider.md +++ b/docs/content/advanced/backend/payment/how-to-create-payment-provider.md @@ -6,7 +6,7 @@ In this document, you’ll learn how to add a Payment Provider to your Medusa se A Payment Provider is the payment method used to authorize, capture, and refund payment, among other actions. An example of a Payment Provider is Stripe. -By default, Medusa has a [manual payment provider](https://github.com/medusajs/medusa/tree/2e6622ec5d0ae19d1782e583e099000f0a93b051/packages/medusa-fulfillment-manual) that has minimal implementation. It can be synonymous with a Cash on Delivery payment method. It allows store operators to manage the payment themselves but still keep track of its different stages on Medusa. +By default, Medusa has a [manual payment provider](https://github.com/medusajs/medusa/tree/master/packages/medusa-payment-manual) that has minimal implementation. It can be synonymous with a Cash on Delivery payment method. It allows store operators to manage the payment themselves but still keep track of its different stages on Medusa. Adding a Payment Provider is as simple as creating a [service](../services/create-service.md) file in `src/services`. A Payment Provider is essentially a service that extends `PaymentService` from `medusa-interfaces`. @@ -42,9 +42,9 @@ These methods are used at different points in the Checkout flow as well as when ![Payment Flows.jpg](https://i.imgur.com/WeDr0ph.jpg) -## Create a Fulfillment Provider +## Create a Payment Provider -The first step to create a fulfillment provider is to create a file in `src/services` with the following content: +The first step to create a payment provider is to create a file in `src/services` with the following content: ```jsx import { PaymentService } from "medusa-interfaces" From db481119633f0bb9cac1c43072748cb3543ac2fe Mon Sep 17 00:00:00 2001 From: Achufusi Ifeanyichukwu <34815628+mkaychuks@users.noreply.github.com> Date: Wed, 11 May 2022 19:00:05 +0100 Subject: [PATCH 33/92] A little punctuation here and there :smile: Read through, capitalized the necessary Nouns and punctuated the page. --- docs/content/quickstart/quick-start.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/content/quickstart/quick-start.md b/docs/content/quickstart/quick-start.md index 75a6db3794..f7022afb69 100644 --- a/docs/content/quickstart/quick-start.md +++ b/docs/content/quickstart/quick-start.md @@ -39,20 +39,20 @@ You can install Node from the [official website](https://nodejs.org/en/). ``` After these four steps and only a couple of minutes, you now have a complete commerce engine running locally. You may now explore [the documentation](https://docs.medusajs.com/api) to learn how to interact with the Medusa API. You may also add [plugins](https://github.com/medusajs/medusa/tree/master/packages) to your Medusa store by specifying them in your `medusa-config.js` file. -We have a prebuilt admin dashboard that you can use to configure and manage your store find it here: [Medusa Admin](https://github.com/medusajs/admin) +We have a prebuilt Admin dashboard that you can use to configure and manage your store find it here: [Medusa Admin](https://github.com/medusajs/admin) ## What's next? ### Set up a storefront for your Medusa project -We have created two starters for you that can help you lay a foundation for your storefront. The starters work with your new server with minimal configuration simply clone the starters from here: +We have created two starters for you that can help you lay a foundation for your storefront. The starters work with your new server with minimal configuration, simply clone the starters from here: - [Nextjs Starter](https://github.com/medusajs/nextjs-starter-medusa) - [Gatsby Starter](https://github.com/medusajs/gatsby-starter-medusa) :::tip -Medusa runs on port 9000 by default and the storefront starters are both configured to run on port 8000. If you wish to run your storefront starter on another port you should update your CORS settings in your project's `medusa-config.js`. +Medusa runs on port 9000 by default and the storefront starters are both configured to run on port 8000. If you wish to run your storefront starter on another port, you should update your CORS settings in your project's `medusa-config.js`. ::: From 59009c50b7e20e848fa31d2753512d9bdb1889d5 Mon Sep 17 00:00:00 2001 From: Achufusi Ifeanyichukwu <34815628+mkaychuks@users.noreply.github.com> Date: Wed, 11 May 2022 19:36:57 +0100 Subject: [PATCH 34/92] syntax changes (#1499) --- docs/content/introduction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/introduction.md b/docs/content/introduction.md index ff50dfeaac..2f0e2518b4 100644 --- a/docs/content/introduction.md +++ b/docs/content/introduction.md @@ -18,7 +18,7 @@ Your Medusa server will include all functionalities related to your store’s ch The admin dashboard is accessible by store operators. Store operators can use the admin dashboard to view, create, and modify data such as orders and products. -Medusa provides a beautiful [admin dashboard](https://demo.medusajs.com) that you can use right off the bat. Our admin dashboard provides a lot of functionalities to manage your store including Order management, product management, user management and more. +Medusa provides a beautiful [admin dashboard](https://demo.medusajs.com) that you can use right off the bat. Our admin dashboard provides a lot of functionalities to manage your store including Order management, Product management, User management and more. You can also create your own admin dashboard by utilizing the [Admin REST APIs](https://docs.medusajs.com/api/admin/auth). From d8222885ec36e9a8f35847b8b8053418f9b062f7 Mon Sep 17 00:00:00 2001 From: Achufusi Ifeanyichukwu <34815628+mkaychuks@users.noreply.github.com> Date: Wed, 11 May 2022 19:37:29 +0100 Subject: [PATCH 35/92] minor punctuations --- .../tutorial/1-creating-your-medusa-server.md | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/content/tutorial/1-creating-your-medusa-server.md b/docs/content/tutorial/1-creating-your-medusa-server.md index 99bc6414b9..c5dee4ace7 100644 --- a/docs/content/tutorial/1-creating-your-medusa-server.md +++ b/docs/content/tutorial/1-creating-your-medusa-server.md @@ -6,15 +6,15 @@ title: Creating your Medusa server ## Introduction -With the required software installed on your computer you are ready to start working on your first Medusa project. +With the required software installed on your computer, you are ready to start working on your first Medusa project. -In this part of the tutorial we will setup the skeleton for a Medusa store and will be making the first requests to your Medusa server. +In this part of the tutorial, we will setup the skeleton for a Medusa store and will be making the first requests to your Medusa server. -Once you have completed this part of the tutorial you will have a powerful backend for digital commerce experiences. The server will be capable of handling orders, ensuring payments are going through, keeping basic product and customer data in sync, etc. You can use one of the frontend starters to quickly hook up your server to a presentation layer ([Gatsby](https://github.com/medusajs/gatsby-starter-medusa) or [Next](https://github.com/medusajs/nextjs-starter-medusa)). +Once you have completed this part of the tutorial, you will have a powerful backend for digital commerce experiences. The server will be capable of handling orders, ensuring payments are going through, keeping basic product and customer data in sync, etc. You can use one of the frontend starters to quickly hook up your server to a presentation layer ([Gatsby](https://github.com/medusajs/gatsby-starter-medusa) or [Next](https://github.com/medusajs/nextjs-starter-medusa)). ## Setup a Medusa project -With Medusa CLI installed it is very easy to setup a new Medusa project, with the `new` command. In your command line run: +With Medusa CLI installed, it is very easy to setup a new Medusa project, with the `new` command. In your command line run: ```shell medusa new my-medusa-server --seed @@ -28,7 +28,7 @@ The command will do a number of things: - create a database in postgres with the name my-medusa-server - the `--seed` flag indicates that the database should be populated with some test data after the project has been set up -If you navigate to the root folder of your new project you will see the following files in your directory: +If you navigate to the root folder of your new project, you will see the following files in your directory: ``` . @@ -43,11 +43,11 @@ If you navigate to the root folder of your new project you will see the followin └── package.json ``` -There is not a lot of files needed to get your Medusa store setup and this is all due to the fact that the main Medusa core (`@medusajs/medusa`) is installed as a dependency in your project giving you all the fundamental needs for a digital commerce experience. +There is not a lot of files needed to get your Medusa store setup and this is all due to the fact that the main Medusa core (`@medusajs/medusa`) is installed as a dependency in your project, giving you all the fundamental needs for a digital commerce experience. Much of Medusa's power lies in the `medusa-config.js` which is the file that configures your store and orchestrates the plugins that you wish to use together with your store. There are some different types of plugin categories such as payment plugins, notification plugins and fulfillment plugins, but plugins can contain any form of extension that enhances your store. -For customizations that are more particular to your project you can extend your Medusa server by adding files in the `api` and `services` directories. More about customizing your server will follow in the following parts. +For customizations that are more particular to your project, you can extend your Medusa server by adding files in the `api` and `services` directories. More about customizing your server will follow in the following parts. ## Starting your Medusa server @@ -64,7 +64,7 @@ cd my-medusa-server medusa develop ``` -If you ran the new command with the `--seed` flag you will already have products available in your store. To view these you can run the following command in your command line: +If you ran the new command with the `--seed` flag, you will already have products available in your store. To view these, you can run the following command in your command line: ```shell curl -X GET localhost:9000/store/products | python -m json.tool @@ -78,19 +78,19 @@ Other options you could take are: ### Add a frontend to your server -We have created two starters for you that can help you lay a foundation for your storefront. The starters work with your new server with minimal configuration simply clone the starters from here: +We have created two starters for you that can help you lay a foundation for your storefront. The starters work with your new server with minimal configuration, simply clone the starters from here: - [Nextjs Starter](https://github.com/medusajs/nextjs-starter-medusa) - [Gatsby Starter](https://github.com/medusajs/gatsby-starter-medusa) ### Browse the API reference -In the API reference docs you can find all the available requests that are exposed by your new Medusa server. Interacting with the API is the first step to creating truly unique experiences. +In the API reference docs, you can find all the available requests that are exposed by your new Medusa server. Interacting with the API is the first step to creating truly unique experiences. ### Setup Stripe as a payment provider (Guide coming soon) -One of the first things you may want to do when building out your store would be to add a payment provider. Your starter project comes with a dummy payment provider that simply fakes payments being processed. In the real world you want a payment provider that can handle credit card information securely and make sure that funds are being transferred to your account. Stripe is one of the most popular payment providers and Medusa has an official plugin that you can easily install in your project. +One of the first things you may want to do when building out your store would be to add a payment provider. Your starter project comes with a dummy payment provider that simply fakes payments being processed. In the real world, you want a payment provider that can handle credit card information securely and make sure that funds are being transferred to your account. Stripe is one of the most popular payment providers and Medusa has an official plugin that you can easily install in your project. ## Summary -In this part of the tutorial we have setup your first Medusa project using the `medusa new` command. You have now reached a key milestone as you are ready to start building your Medusa store; from here there are no limits to how you can use Medusa as you can customize and extend the functionality of the core. In the next part of the tutorial we will be exploring how you can add custom services and endpoints to fit your exact needs. +In this part of the tutorial, we have setup your first Medusa project using the `medusa new` command. You have now reached a key milestone as you are ready to start building your Medusa store; from here there are no limits to how you can use Medusa, as you can customize and extend the functionality of the core. In the next part of the tutorial, we will be exploring how you can add custom services and endpoints to fit your exact needs. From a69b52e031748c705541e804933f71927b7a192e Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Thu, 12 May 2022 09:22:46 +0700 Subject: [PATCH 36/92] Feat: Filter price lists by customer group (#1431) * add customer groups to price list factory * add integration test for filtering price lists by customer group * normalize list price list query * add customer groups to list-price-list queryparameters * query based on customergroups if they exist for price lists * remove verbose flag * add another price list with a customer group * remove console.log * pr feedback * add query type to repository * add query type to repository * set groups to undefined instead of deleting parameter * remove wildcard destructing * make buildQuery type specific to price lists * steal Adriens types * fix(medusa): support searching for price lists (#1407) * delete instead of settting groups to undefined * add groups to query with q * use simple customer group factory instead of manual creation * Update simple-customer-group-factory.ts * remove comma that breaks integration-tests Co-authored-by: Zakaria El Asri <33696020+zakariaelas@users.noreply.github.com> --- .../api/__tests__/admin/price-list.js | 46 +++++++++++++++++ .../simple-customer-group-factory.ts | 2 +- .../factories/simple-price-list-factory.ts | 19 ++----- .../src/api/routes/admin/price-lists/index.ts | 6 ++- .../medusa/src/repositories/price-list.ts | 50 +++++++++++++++---- packages/medusa/src/services/price-list.ts | 44 ++++++++++------ packages/medusa/src/types/price-list.ts | 6 ++- 7 files changed, 129 insertions(+), 44 deletions(-) diff --git a/integration-tests/api/__tests__/admin/price-list.js b/integration-tests/api/__tests__/admin/price-list.js index 7291a4f956..07027628a1 100644 --- a/integration-tests/api/__tests__/admin/price-list.js +++ b/integration-tests/api/__tests__/admin/price-list.js @@ -1,3 +1,4 @@ +const { PriceList, CustomerGroup } = require("@medusajs/medusa") const path = require("path") const setupServer = require("../../../helpers/setup-server") @@ -8,6 +9,9 @@ const { simpleProductFactory, simplePriceListFactory, } = require("../../factories") +const { + simpleCustomerGroupFactory, +} = require("../../factories/simple-customer-group-factory") const adminSeeder = require("../../helpers/admin-seeder") const customerSeeder = require("../../helpers/customer-seeder") const priceListSeeder = require("../../helpers/price-list-seeder") @@ -322,6 +326,48 @@ describe("/admin/price-lists", () => { ) expect(response.data.count).toEqual(1) }) + + it("lists only price lists with customer_group", async () => { + await customerSeeder(dbConnection) + + await simplePriceListFactory(dbConnection, { + id: "test-list-cgroup-1", + customer_groups: ["customer-group-1"], + }) + await simplePriceListFactory(dbConnection, { + id: "test-list-cgroup-2", + customer_groups: ["customer-group-2"], + }) + await simplePriceListFactory(dbConnection, { + id: "test-list-cgroup-3", + customer_groups: ["customer-group-3"], + }) + await simplePriceListFactory(dbConnection, { + id: "test-list-no-cgroup", + }) + + const api = useApi() + + const response = await api + .get( + `/admin/price-lists?customer_groups[]=customer-group-1,customer-group-2`, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.warn(err.response.data) + }) + + expect(response.status).toEqual(200) + expect(response.data.price_lists.length).toEqual(2) + expect(response.data.price_lists).toEqual([ + expect.objectContaining({ id: "test-list-cgroup-1" }), + expect.objectContaining({ id: "test-list-cgroup-2" }), + ]) + }) }) describe("POST /admin/price-lists/:id", () => { diff --git a/integration-tests/api/factories/simple-customer-group-factory.ts b/integration-tests/api/factories/simple-customer-group-factory.ts index 7afa566ff9..4d4f85a168 100644 --- a/integration-tests/api/factories/simple-customer-group-factory.ts +++ b/integration-tests/api/factories/simple-customer-group-factory.ts @@ -22,7 +22,7 @@ export const simpleCustomerGroupFactory = async ( data.id || `simple-customer-group-${Math.random() * 1000}` const c = manager.create(CustomerGroup, { id: customerGroupId, - name: data.name, + name: data.name || faker.company.companyName(), }) const group = await manager.save(c) diff --git a/integration-tests/api/factories/simple-price-list-factory.ts b/integration-tests/api/factories/simple-price-list-factory.ts index a20380d3b0..f8d5c9d9f0 100644 --- a/integration-tests/api/factories/simple-price-list-factory.ts +++ b/integration-tests/api/factories/simple-price-list-factory.ts @@ -7,6 +7,7 @@ import { } from "@medusajs/medusa" import faker from "faker" import { Connection } from "typeorm" +import { simpleCustomerGroupFactory } from "./simple-customer-group-factory" type ProductListPrice = { variant_id: string @@ -42,22 +43,10 @@ export const simplePriceListFactory = async ( let customerGroups = [] if (typeof data.customer_groups !== "undefined") { - await manager - .createQueryBuilder() - .insert() - .into(CustomerGroup) - .values( - data.customer_groups.map((group) => ({ - id: group, - name: faker.company.companyName(), - })) + customerGroups = await Promise.all( + data.customer_groups.map((group) => + simpleCustomerGroupFactory(connection, { id: group }) ) - .orIgnore() - .execute() - - customerGroups = await manager.findByIds( - CustomerGroup, - data.customer_groups ) } diff --git a/packages/medusa/src/api/routes/admin/price-lists/index.ts b/packages/medusa/src/api/routes/admin/price-lists/index.ts index 8c8ec7cc3e..4a5fbbddb0 100644 --- a/packages/medusa/src/api/routes/admin/price-lists/index.ts +++ b/packages/medusa/src/api/routes/admin/price-lists/index.ts @@ -11,7 +11,11 @@ export default (app) => { route.get("/:id", middlewares.wrap(require("./get-price-list").default)) - route.get("/", middlewares.wrap(require("./list-price-lists").default)) + route.get( + "/", + middlewares.normalizeQuery(), + middlewares.wrap(require("./list-price-lists").default) + ) route.get( "/:id/products", diff --git a/packages/medusa/src/repositories/price-list.ts b/packages/medusa/src/repositories/price-list.ts index 0eac64a2d2..37b3b9e468 100644 --- a/packages/medusa/src/repositories/price-list.ts +++ b/packages/medusa/src/repositories/price-list.ts @@ -2,22 +2,26 @@ import { groupBy, map } from "lodash" import { Brackets, EntityRepository, - FindManyOptions, Repository + FindManyOptions, + FindOperator, + Repository, } from "typeorm" import { PriceList } from "../models/price-list" -import { CustomFindOptions } from "../types/common" +import { CustomFindOptions, ExtendedFindConfig } from "../types/common" -type PriceListFindOptions = CustomFindOptions +type PriceListFindOptions = CustomFindOptions @EntityRepository(PriceList) export class PriceListRepository extends Repository { public async getFreeTextSearchResultsAndCount( q: string, options: PriceListFindOptions = { where: {} }, + groups?: FindOperator, relations: (keyof PriceList)[] = [] ): Promise<[PriceList[], number]> { options.where = options.where ?? {} - let qb = this.createQueryBuilder("price_list") + + const qb = this.createQueryBuilder("price_list") .leftJoinAndSelect("price_list.customer_groups", "customer_group") .select(["price_list.id"]) .where(options.where) @@ -31,6 +35,10 @@ export class PriceListRepository extends Repository { .skip(options.skip) .take(options.take) + if (groups) { + qb.andWhere("group.id IN (:...ids)", { ids: groups.value }) + } + const [results, count] = await qb.getManyAndCount() const price_lists = await this.findWithRelations( @@ -53,7 +61,6 @@ export class PriceListRepository extends Repository { } else { entities = await this.find(idsOrOptionsWithoutRelations) } - const groupedRelations: Record = {} for (const relation of relations) { const [topLevel] = relation.split(".") @@ -63,7 +70,6 @@ export class PriceListRepository extends Repository { groupedRelations[topLevel] = [relation] } } - const entitiesIds = entities.map(({ id }) => id) const entitiesIdsWithRelations = await Promise.all( Object.values(groupedRelations).map((relations: string[]) => { @@ -72,7 +78,7 @@ export class PriceListRepository extends Repository { relations: relations as string[], }) }) - ).then(entitiesIdsWithRelations => entitiesIdsWithRelations.flat()) + ).then((entitiesIdsWithRelations) => entitiesIdsWithRelations.flat()) const entitiesAndRelations = entitiesIdsWithRelations.concat(entities) const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id") @@ -87,9 +93,31 @@ export class PriceListRepository extends Repository { ): Promise { options.take = 1 - return (await this.findWithRelations( - relations, - options - ))?.pop() + return (await this.findWithRelations(relations, options))?.pop() + } + + async listAndCount( + query: ExtendedFindConfig, + groups?: FindOperator + ): Promise<[PriceList[], number]> { + const qb = this.createQueryBuilder("price_list") + .where(query.where) + .skip(query.skip) + .take(query.take) + + if (groups) { + qb.leftJoinAndSelect("price_list.customer_groups", "group").andWhere( + "group.id IN (:...ids)", + { ids: groups.value } + ) + } + + if (query.relations?.length) { + query.relations.forEach((rel) => { + qb.leftJoinAndSelect(`price_list.${rel}`, rel) + }) + } + + return await qb.getManyAndCount() } } diff --git a/packages/medusa/src/services/price-list.ts b/packages/medusa/src/services/price-list.ts index bb068a3ad4..c0eb750f7d 100644 --- a/packages/medusa/src/services/price-list.ts +++ b/packages/medusa/src/services/price-list.ts @@ -252,10 +252,18 @@ class PriceListService extends BaseService { selector: FilterablePriceListProps = {}, config: FindConfig = { skip: 0, take: 20 } ): Promise { - const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_) + return await this.atomicPhase_(async (manager: EntityManager) => { + const priceListRepo = manager.getCustomRepository(this.priceListRepo_) - const query = this.buildQuery_(selector, config) - return await priceListRepo.find(query) + const query = this.buildQuery_(selector, config) + + const groups = query.where.customer_groups + query.where.customer_groups = undefined + + const [priceLists] = await priceListRepo.listAndCount(query, groups) + + return priceLists + }) } /** @@ -268,19 +276,25 @@ class PriceListService extends BaseService { selector: FilterablePriceListProps = {}, config: FindConfig = { skip: 0, take: 20 } ): Promise<[PriceList[], number]> { - const priceListRepo = this.manager_.getCustomRepository(this.priceListRepo_) - const q = selector.q - const { relations, ...query } = this.buildQuery_(selector, config) + return await this.atomicPhase_(async (manager: EntityManager) => { + const priceListRepo = manager.getCustomRepository(this.priceListRepo_) + const q = selector.q + const { relations, ...query } = this.buildQuery_(selector, config) - if (q) { - delete query.where.q - return await priceListRepo.getFreeTextSearchResultsAndCount( - q, - query, - relations - ) - } - return await priceListRepo.findAndCount({ ...query, relations }) + const groups = query.where.customer_groups + delete query.where.customer_groups + + if (q) { + delete query.where.q + return await priceListRepo.getFreeTextSearchResultsAndCount( + q, + query, + groups, + relations + ) + } + return await priceListRepo.listAndCount({ ...query, relations }, groups) + }) } async upsertCustomerGroups_( diff --git a/packages/medusa/src/types/price-list.ts b/packages/medusa/src/types/price-list.ts index 42aae61aff..bee6195c88 100644 --- a/packages/medusa/src/types/price-list.ts +++ b/packages/medusa/src/types/price-list.ts @@ -9,7 +9,7 @@ import { ValidateNested, } from "class-validator" import { PriceList } from "../models/price-list" -import { DateComparisonOperator } from "./common" +import { DateComparisonOperator, FindConfig } from "./common" import { XorConstraint } from "./validators/xor" export enum PriceListType { @@ -39,6 +39,10 @@ export class FilterablePriceListProps { @IsOptional() name?: string + @IsOptional() + @IsString({ each: true }) + customer_groups?: string[] + @IsString() @IsOptional() description?: string From 66934db0db4d30c3b7808f0c512fc13e7f56de22 Mon Sep 17 00:00:00 2001 From: Achufusi Ifeanyichukwu <34815628+mkaychuks@users.noreply.github.com> Date: Thu, 12 May 2022 17:33:06 +0100 Subject: [PATCH 37/92] Update setting-up-a-nextjs-storefront-for-your-medusa-project.md Made minor syntax and sentence changes, for your kind approval!! --- ...ting-up-a-nextjs-storefront-for-your-medusa-project.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/content/how-to/setting-up-a-nextjs-storefront-for-your-medusa-project.md b/docs/content/how-to/setting-up-a-nextjs-storefront-for-your-medusa-project.md index b9015c23c5..fc8b1faaeb 100644 --- a/docs/content/how-to/setting-up-a-nextjs-storefront-for-your-medusa-project.md +++ b/docs/content/how-to/setting-up-a-nextjs-storefront-for-your-medusa-project.md @@ -15,7 +15,7 @@ This article assumes you already have the Medusa project created and ready to be ## Getting started -In order to get started let's open the terminal and use the following command to create an instance of your storefront: +In order to get started, let's open the terminal and use the following command to create an instance of your storefront: ```zsh npx create-next-app -e https://github.com/medusajs/nextjs-starter-medusa my-medusa-storefront @@ -31,7 +31,7 @@ Let's jump to these two. For this part, we should navigate to a `client.js` file which you can find in the utils folder. -We don't need to do much in here, but to make sure that our storefront is pointing to the port, where the server is running +We don't need to do much in here, but to make sure that our storefront is pointing to the port where the server is running ```js import Medusa from "@medusajs/medusa-js" @@ -39,13 +39,13 @@ const BACKEND_URL = process.env.GATSBY_STORE_URL || "http://localhost:9000" // < export const createClient = () => new Medusa({ baseUrl: BACKEND_URL }) ``` -By default the Medusa server is running at port 9000, so if you didn't change that we are good to go to our next step. +By default, the Medusa server is running at port 9000. So if you didn't change that, we are good to go to our next step. ## Update the `STORE_CORS` variable Here let's navigate to your Medusa server and open `medusa-config.js` -Let's locate the `STORE_CORS` variable and make sure it's the right port (which is 3000 by default for Next.js projects) +Let's locate the `STORE_CORS` variable and make sure it's the right port (which is 3000 by default for Next.js projects). ```js /* From 42f7b1e73684d5dfeb9b915dc4736be8114bb78e Mon Sep 17 00:00:00 2001 From: Achufusi Ifeanyichukwu <34815628+mkaychuks@users.noreply.github.com> Date: Thu, 12 May 2022 17:47:41 +0100 Subject: [PATCH 38/92] Update create-medusa-app.md Capitalized some words and minor syntax changes. --- docs/content/how-to/create-medusa-app.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/content/how-to/create-medusa-app.md b/docs/content/how-to/create-medusa-app.md index ef514a8678..27c78a2267 100644 --- a/docs/content/how-to/create-medusa-app.md +++ b/docs/content/how-to/create-medusa-app.md @@ -55,7 +55,7 @@ For the walkthrough purposes, we assume that the selected starter is `medusa-sta ### Selecting a Storefront -After selecting your Medusa starter you will be given the option to install one of our storefront starters. At the moment we have starters for Gatsby and Next.js: +After selecting your Medusa starter, you will be given the option to install one of our storefront starters. At the moment, we have starters for Gatsby and Next.js: ```bash Which storefront starter would you like to install? … @@ -74,7 +74,7 @@ Creating new project from git: https://github.com/medusajs/medusa-starter-defaul Installing packages... ``` -Once the installation has been completed you will have a Medusa backend, a demo storefront, and an admin dashboard. +Once the installation has been completed, you will have a Medusa backend, a Demo storefront, and an Admin dashboard. ## What's inside @@ -87,7 +87,7 @@ Inside the root folder which was specified at the beginning of the installation /admin // Medusa admin panel ``` -`create-medusa-app` prints out the commands that are available to you after installation. When each project is started you can visit your storefront, complete the order, and view the order in Medusa admin. +`create-medusa-app` prints out the commands that are available to you after installation. When each project is started, you can visit your storefront, complete the order, and view the order in Medusa admin. ```bash ⠴ Installing packages... @@ -112,10 +112,10 @@ Create initial git commit in my-medusa-store/admin ## **What's next?** -To learn more about Medusa to go through our docs to get some inspiration and guidance for the next steps and further development: +To learn more about Medusa, go through our docs to get some inspiration and guidance for the next steps and further development: - [Find out how to set up a Medusa project with Gatsby and Contentful](https://docs.medusajs.com/how-to/headless-ecommerce-store-with-gatsby-contentful-medusa) - [Move your Medusa setup to the next level with some custom functionality](https://docs.medusajs.com/tutorial/adding-custom-functionality) - [Create your own Medusa plugin](https://docs.medusajs.com/guides/plugins) -If you have any follow-up questions or want to chat directly with our engineering team we are always happy to meet you at our [Discord](https://discord.gg/DSHySyMu). +If you have any follow-up questions or want to chat directly with our engineering team, we are always happy to meet you at our [Discord](https://discord.gg/DSHySyMu). From 9ca45ea492e755a88737322f900d60abdfa64024 Mon Sep 17 00:00:00 2001 From: Oliver Windall Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Fri, 13 May 2022 12:42:23 +0200 Subject: [PATCH 39/92] feat(medusa): Add endpoints specific to DiscountConditions (#1355) --- .../admin/__snapshots__/discount.js.snap | 125 ++++ .../api/__tests__/admin/discount.js | 544 ++++++++++++++++-- .../simple-discount-condition-factory.ts | 11 +- .../src/resources/admin/discounts.ts | 56 +- .../medusa-react/mocks/data/fixtures.json | 9 +- packages/medusa-react/mocks/handlers/admin.ts | 37 ++ .../src/hooks/admin/discounts/mutations.ts | 52 +- .../hooks/admin/discounts/mutations.test.ts | 69 +++ .../admin/discounts/create-condition.ts | 107 ++++ .../routes/admin/discounts/create-discount.ts | 2 - .../admin/discounts/delete-condition.ts | 109 ++++ .../src/api/routes/admin/discounts/index.ts | 17 + .../admin/discounts/update-condition.ts | 109 ++++ .../medusa/src/models/discount-condition.ts | 4 + .../src/repositories/discount-condition.ts | 16 + packages/medusa/src/repositories/order.ts | 2 +- .../medusa/src/services/discount-condition.ts | 179 ++++++ packages/medusa/src/services/discount.ts | 128 +---- packages/medusa/src/services/product.js | 15 +- packages/medusa/src/types/discount.ts | 1 + 20 files changed, 1433 insertions(+), 159 deletions(-) create mode 100644 packages/medusa/src/api/routes/admin/discounts/create-condition.ts create mode 100644 packages/medusa/src/api/routes/admin/discounts/delete-condition.ts create mode 100644 packages/medusa/src/api/routes/admin/discounts/update-condition.ts create mode 100644 packages/medusa/src/services/discount-condition.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/discount.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/discount.js.snap index a3a4b59fcd..3299580946 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/discount.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/discount.js.snap @@ -14,3 +14,128 @@ Object { "type": "duplicate_error", } `; + +exports[`/admin/discounts POST /admin/discounts/:id/conditions fails if more than one condition type is provided: Only one of products, customer_groups is allowed, Only one of customer_groups, products is allowed 1`] = `"Request failed with status code 400"`; + +exports[`/admin/discounts POST /admin/discounts/:id/conditions should create a condition 1`] = ` +Object { + "code": "TEST", + "created_at": Any, + "deleted_at": null, + "ends_at": null, + "id": "test-discount", + "is_disabled": false, + "is_dynamic": false, + "metadata": null, + "parent_discount": null, + "parent_discount_id": null, + "regions": Array [], + "rule": Object { + "allocation": "total", + "conditions": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "discount_rule_id": Any, + "id": Any, + "metadata": null, + "operator": "in", + "type": "products", + "updated_at": Any, + }, + ], + "created_at": Any, + "deleted_at": null, + "description": null, + "id": Any, + "metadata": null, + "type": "percentage", + "updated_at": Any, + "value": 10, + }, + "rule_id": Any, + "starts_at": Any, + "updated_at": Any, + "usage_count": 0, + "usage_limit": null, + "valid_duration": null, +} +`; + +exports[`/admin/discounts POST /admin/discounts/:id/conditions throws if discount does not exist: Discount with id does-not-exist was not found 1`] = `"Request failed with status code 404"`; + +exports[`/admin/discounts POST /admin/discounts/:id/conditions/:condition_id should update a condition 1`] = ` +Object { + "code": "TEST", + "created_at": Any, + "deleted_at": null, + "ends_at": null, + "id": "test-discount", + "is_disabled": false, + "is_dynamic": false, + "metadata": null, + "parent_discount_id": null, + "rule": Object { + "allocation": "total", + "conditions": Array [ + Object { + "created_at": Any, + "deleted_at": null, + "discount_rule_id": Any, + "id": Any, + "metadata": null, + "operator": "in", + "products": Array [ + Object { + "collection_id": null, + "created_at": Any, + "deleted_at": null, + "description": null, + "discountable": true, + "external_id": null, + "handle": null, + "height": null, + "hs_code": null, + "id": "test-product", + "is_giftcard": false, + "length": null, + "material": null, + "metadata": null, + "mid_code": null, + "origin_country": null, + "profile_id": Any, + "status": "draft", + "subtitle": null, + "thumbnail": null, + "title": "Practical Frozen Fish", + "type_id": Any, + "updated_at": Any, + "weight": null, + "width": null, + }, + ], + "type": "products", + "updated_at": Any, + }, + ], + "created_at": Any, + "deleted_at": null, + "description": null, + "id": Any, + "metadata": null, + "type": "percentage", + "updated_at": Any, + "value": 10, + }, + "rule_id": Any, + "starts_at": Any, + "updated_at": Any, + "usage_count": 0, + "usage_limit": null, + "valid_duration": null, +} +`; + +exports[`/admin/discounts POST /admin/discounts/:id/conditions/:condition_id throws if condition does not exist: DiscountCondition with id does-not-exist was not found for Discount test-discount 1`] = `"Request failed with status code 404"`; + +exports[`/admin/discounts POST /admin/discounts/:id/conditions/:condition_id throws if discount does not exist: Discount with id does-not-exist was not found 1`] = `"Request failed with status code 404"`; diff --git a/integration-tests/api/__tests__/admin/discount.js b/integration-tests/api/__tests__/admin/discount.js index a9475647a0..3957b122db 100644 --- a/integration-tests/api/__tests__/admin/discount.js +++ b/integration-tests/api/__tests__/admin/discount.js @@ -1,6 +1,5 @@ const path = require("path") const { - Region, DiscountRule, Discount, Customer, @@ -12,7 +11,6 @@ const { useApi } = require("../../../helpers/use-api") const { initDb, useDb } = require("../../../helpers/use-db") const adminSeeder = require("../../helpers/admin-seeder") const discountSeeder = require("../../helpers/discount-seeder") -const { exportAllDeclaration } = require("@babel/types") const { simpleProductFactory } = require("../../factories") const { simpleDiscountFactory, @@ -433,7 +431,6 @@ describe("/admin/discounts", () => { await adminSeeder(dbConnection) await discountSeeder(dbConnection) } catch (err) { - console.log(err) throw err } }) @@ -1468,41 +1465,37 @@ describe("/admin/discounts", () => { describe("POST /admin/discounts/:discount_id/dynamic-codes", () => { beforeEach(async () => { const manager = dbConnection.manager - try { - await adminSeeder(dbConnection) - await manager.insert(DiscountRule, { - id: "test-discount-rule", - description: "Dynamic rule", - type: "percentage", - value: 10, - allocation: "total", - }) - await manager.insert(Discount, { - id: "test-discount", - code: "DYNAMIC", - is_dynamic: true, - is_disabled: false, - rule_id: "test-discount-rule", - valid_duration: "P2Y", - }) - await manager.insert(DiscountRule, { - id: "test-discount-rule1", - description: "Dynamic rule", - type: "percentage", - value: 10, - allocation: "total", - }) - await manager.insert(Discount, { - id: "test-discount1", - code: "DYNAMICCode", - is_dynamic: true, - is_disabled: false, - rule_id: "test-discount-rule1", - }) - } catch (err) { - console.log(err) - throw err - } + + await adminSeeder(dbConnection) + await manager.insert(DiscountRule, { + id: "test-discount-rule", + description: "Dynamic rule", + type: "percentage", + value: 10, + allocation: "total", + }) + await manager.insert(Discount, { + id: "test-discount", + code: "DYNAMIC", + is_dynamic: true, + is_disabled: false, + rule_id: "test-discount-rule", + valid_duration: "P2Y", + }) + await manager.insert(DiscountRule, { + id: "test-discount-rule1", + description: "Dynamic rule", + type: "percentage", + value: 10, + allocation: "total", + }) + await manager.insert(Discount, { + id: "test-discount1", + code: "DYNAMICCode", + is_dynamic: true, + is_disabled: false, + rule_id: "test-discount-rule1", + }) }) afterEach(async () => { @@ -1554,7 +1547,7 @@ describe("/admin/discounts", () => { } ) .catch((err) => { - // console.log(err) + console.log(err) }) expect(response.status).toEqual(200) @@ -1566,4 +1559,477 @@ describe("/admin/discounts", () => { ) }) }) + + describe("DELETE /admin/discounts/:id/conditions/:condition_id", () => { + beforeEach(async () => { + const manager = dbConnection.manager + await adminSeeder(dbConnection) + + await manager.insert(DiscountRule, { + id: "test-discount-rule-fixed", + description: "Test discount rule", + type: "fixed", + value: 10, + allocation: "total", + }) + + const prod = await simpleProductFactory(dbConnection, { type: "pants" }) + + await simpleDiscountFactory(dbConnection, { + id: "test-discount", + code: "TEST", + rule: { + type: "percentage", + value: "10", + allocation: "total", + conditions: [ + { + type: "products", + operator: "in", + products: [prod.id], + }, + ], + }, + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should delete condition", async () => { + const api = useApi() + + const group = await dbConnection.manager.insert(CustomerGroup, { + id: "customer-group-1", + name: "vip-customers", + }) + + await dbConnection.manager.insert(Customer, { + id: "cus_1234", + email: "oli@email.com", + groups: [group], + }) + + await simpleDiscountFactory(dbConnection, { + id: "test-discount", + code: "TEST", + rule: { + type: "percentage", + value: "10", + allocation: "total", + conditions: [ + { + id: "test-condition", + type: "customer_groups", + operator: "in", + customer_groups: ["customer-group-1"], + }, + ], + }, + }) + + const response = await api + .delete("/admin/discounts/test-discount/conditions/test-condition", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + const disc = response.data + + expect(response.status).toEqual(200) + expect(disc).toEqual( + expect.objectContaining({ + id: "test-condition", + deleted: true, + object: "discount-condition", + }) + ) + }) + + it("should not fail if condition does not exist", async () => { + const api = useApi() + + const response = await api + .delete("/admin/discounts/test-discount/conditions/test-condition", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + const disc = response.data + + expect(response.status).toEqual(200) + expect(disc).toEqual( + expect.objectContaining({ + id: "test-condition", + deleted: true, + object: "discount-condition", + }) + ) + }) + + it("should fail if discount does not exist", async () => { + const api = useApi() + + try { + await api.delete( + "/admin/discounts/not-exist/conditions/test-condition", + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + } catch (error) { + expect(error.message).toMatchSnapshot( + "Discount with id not-exist was not found" + ) + } + }) + }) + + describe("POST /admin/discounts/:id/conditions", () => { + beforeEach(async () => { + const manager = dbConnection.manager + await adminSeeder(dbConnection) + + await manager.insert(DiscountRule, { + id: "test-discount-rule-fixed", + description: "Test discount rule", + type: "fixed", + value: 10, + allocation: "total", + }) + + await simpleDiscountFactory(dbConnection, { + id: "test-discount", + code: "TEST", + rule: { + type: "percentage", + value: "10", + allocation: "total", + }, + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should create a condition", async () => { + const api = useApi() + + const prod = await simpleProductFactory(dbConnection, { type: "pants" }) + + const response = await api + .post( + "/admin/discounts/test-discount/conditions", + { + operator: "in", + products: [prod.id], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + const disc = response.data.discount + + expect(response.status).toEqual(200) + expect(disc).toMatchSnapshot({ + id: "test-discount", + code: "TEST", + created_at: expect.any(String), + updated_at: expect.any(String), + rule_id: expect.any(String), + starts_at: expect.any(String), + rule: { + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + conditions: [ + { + id: expect.any(String), + discount_rule_id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + }, + ], + }, + }) + }) + + it("fails if more than one condition type is provided", async () => { + const api = useApi() + + const prod = await simpleProductFactory(dbConnection, { type: "pants" }) + + const group = await dbConnection.manager.insert(CustomerGroup, { + id: "customer-group-1", + name: "vip-customers", + }) + + await dbConnection.manager.insert(Customer, { + id: "cus_1234", + email: "oli@email.com", + groups: [group], + }) + + try { + await api.post( + "/admin/discounts/test-discount/conditions", + { + operator: "in", + products: [prod.id], + customer_groups: ["customer-group-1"], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + } catch (error) { + expect(error.message).toMatchSnapshot( + "Only one of products, customer_groups is allowed, Only one of customer_groups, products is allowed" + ) + } + }) + + it("throws if discount does not exist", async () => { + expect.assertions(1) + + const api = useApi() + + const prod2 = await simpleProductFactory(dbConnection, { type: "pants" }) + + try { + await api.post( + "/admin/discounts/does-not-exist/conditions/test-condition", + { + products: [prod2.id], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + } catch (error) { + expect(error.message).toMatchSnapshot( + "Discount with id does-not-exist was not found" + ) + } + }) + }) + + describe("POST /admin/discounts/:id/conditions/:condition_id", () => { + beforeEach(async () => { + const manager = dbConnection.manager + await adminSeeder(dbConnection) + + await manager.insert(DiscountRule, { + id: "test-discount-rule-fixed", + description: "Test discount rule", + type: "fixed", + value: 10, + allocation: "total", + }) + + const prod = await simpleProductFactory(dbConnection, { type: "pants" }) + + await simpleDiscountFactory(dbConnection, { + id: "test-discount", + code: "TEST", + rule: { + type: "percentage", + value: "10", + allocation: "total", + conditions: [ + { + id: "test-condition", + type: "products", + operator: "in", + products: [prod.id], + }, + ], + }, + }) + + await simpleDiscountFactory(dbConnection, { + id: "test-discount-2", + code: "TEST2", + rule: { + type: "percentage", + value: "10", + allocation: "total", + }, + }) + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should update a condition", async () => { + const api = useApi() + + const prod2 = await simpleProductFactory(dbConnection, { + id: "test-product", + type: "pants", + }) + + const discount = await api + .get("/admin/discounts/test-discount", { + headers: { + Authorization: "Bearer test_token", + }, + }) + .catch((err) => { + console.log(err) + }) + + const cond = discount.data.discount.rule.conditions[0] + + const response = await api + .post( + `/admin/discounts/test-discount/conditions/${cond.id}?expand=rule,rule.conditions,rule.conditions.products`, + { + products: [prod2.id], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + .catch((err) => { + console.log(err) + }) + + const disc = response.data.discount + + expect(response.status).toEqual(200) + expect(disc).toMatchSnapshot({ + id: "test-discount", + code: "TEST", + created_at: expect.any(String), + updated_at: expect.any(String), + rule_id: expect.any(String), + starts_at: expect.any(String), + rule: { + id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + conditions: [ + { + id: expect.any(String), + discount_rule_id: expect.any(String), + created_at: expect.any(String), + updated_at: expect.any(String), + products: [ + { + created_at: expect.any(String), + updated_at: expect.any(String), + profile_id: expect.any(String), + type_id: expect.any(String), + id: "test-product", + }, + ], + }, + ], + }, + }) + }) + + it("throws if condition does not exist", async () => { + const api = useApi() + + const prod2 = await simpleProductFactory(dbConnection, { type: "pants" }) + + try { + await api.post( + "/admin/discounts/test-discount/conditions/does-not-exist", + { + products: [prod2.id], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + } catch (error) { + expect(error.message).toMatchSnapshot( + "DiscountCondition with id does-not-exist was not found for Discount test-discount" + ) + } + }) + + it("throws if discount does not exist", async () => { + expect.assertions(1) + + const api = useApi() + + const prod2 = await simpleProductFactory(dbConnection, { type: "pants" }) + + try { + await api.post( + "/admin/discounts/does-not-exist/conditions/test-condition", + { + products: [prod2.id], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + } catch (error) { + expect(error.message).toMatchSnapshot( + "Discount with id does-not-exist was not found" + ) + } + }) + + it("throws if condition does not belong to discount", async () => { + const api = useApi() + + const prod2 = await simpleProductFactory(dbConnection, { type: "pants" }) + + try { + await api.post( + "/admin/discounts/test-discount-2/conditions/test-condition", + { + products: [prod2.id], + }, + { + headers: { + Authorization: "Bearer test_token", + }, + } + ) + } catch (error) { + expect(error.message).toMatchSnapshot( + "DiscountCondition with id test-condition was not found for Discount test-discount-2" + ) + } + }) + }) }) diff --git a/integration-tests/api/factories/simple-discount-condition-factory.ts b/integration-tests/api/factories/simple-discount-condition-factory.ts index ad3582cc66..9735f8a2b3 100644 --- a/integration-tests/api/factories/simple-discount-condition-factory.ts +++ b/integration-tests/api/factories/simple-discount-condition-factory.ts @@ -13,6 +13,7 @@ import faker from "faker" import { Connection } from "typeorm" export type DiscuntConditionFactoryData = { + id?: string rule_id: string type: DiscountConditionType operator: DiscountConditionOperator @@ -93,11 +94,17 @@ export const simpleDiscountConditionFactory = async ( resources = data.customer_groups } - const condToSave = manager.create(DiscountCondition, { + const toCreate = { type: data.type, operator: data.operator, discount_rule_id: data.rule_id, - }) + } + + if (data.id) { + toCreate["id"] = data.id + } + + const condToSave = manager.create(DiscountCondition, toCreate) const { conditionTable, resourceKey } = getJoinTableResourceIdentifiers( data.type diff --git a/packages/medusa-js/src/resources/admin/discounts.ts b/packages/medusa-js/src/resources/admin/discounts.ts index b0048bc92e..95cba9fa89 100644 --- a/packages/medusa-js/src/resources/admin/discounts.ts +++ b/packages/medusa-js/src/resources/admin/discounts.ts @@ -2,8 +2,11 @@ import { AdminDiscountsDeleteRes, AdminDiscountsListRes, AdminDiscountsRes, - AdminGetDiscountParams, AdminGetDiscountsParams, + AdminPostDiscountsDiscountConditions, + AdminPostDiscountsDiscountConditionsCondition, + AdminPostDiscountsDiscountConditionsConditionParams, + AdminPostDiscountsDiscountConditionsParams, AdminPostDiscountsDiscountDynamicCodesReq, AdminPostDiscountsDiscountReq, AdminPostDiscountsReq, @@ -133,6 +136,57 @@ class AdminDiscountsResource extends BaseResource { const path = `/admin/discounts/${id}/regions/${regionId}` return this.client.request("DELETE", path, {}, {}, customHeaders) } + + /** + * @description creates a discount condition + */ + createCondition( + discountId: string, + payload: AdminPostDiscountsDiscountConditions, + query: AdminPostDiscountsDiscountConditionsParams = {}, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/discounts/${discountId}/conditions` + + if (query) { + const queryString = qs.stringify(query) + path = `/admin/discounts/${discountId}/conditions?${queryString}` + } + + return this.client.request("POST", path, payload, {}, customHeaders) + } + + /** + * @description Updates a discount condition + */ + updateCondition( + discountId: string, + conditionId: string, + payload: AdminPostDiscountsDiscountConditionsCondition, + query: AdminPostDiscountsDiscountConditionsConditionParams = {}, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/discounts/${discountId}/conditions/${conditionId}` + + if (query) { + const queryString = qs.stringify(query) + path = `/admin/discounts/${discountId}/conditions/${conditionId}?${queryString}` + } + + return this.client.request("POST", path, payload, {}, customHeaders) + } + + /** + * @description Removes a condition from a discount + */ + deleteCondition( + discountId: string, + conditionId: string, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/discounts/${discountId}/conditions/${conditionId}` + return this.client.request("DELETE", path, {}, {}, customHeaders) + } } export default AdminDiscountsResource diff --git a/packages/medusa-react/mocks/data/fixtures.json b/packages/medusa-react/mocks/data/fixtures.json index 5223dacbca..a1afaea812 100644 --- a/packages/medusa-react/mocks/data/fixtures.json +++ b/packages/medusa-react/mocks/data/fixtures.json @@ -478,7 +478,14 @@ "created_at": "2021-03-16T21:24:16.872Z", "updated_at": "2021-03-16T21:24:16.872Z", "deleted_at": null, - "metadata": null + "metadata": null, + "conditions": [ + { + "id": "cnd_01F0YESMVJXKX8XZ6Q9Z6Q6Z6", + "type": "products", + "operator": "in" + } + ] }, "is_disabled": false, "parent_discount_id": null, diff --git a/packages/medusa-react/mocks/handlers/admin.ts b/packages/medusa-react/mocks/handlers/admin.ts index 271012c0f7..3a3238eeff 100644 --- a/packages/medusa-react/mocks/handlers/admin.ts +++ b/packages/medusa-react/mocks/handlers/admin.ts @@ -762,6 +762,43 @@ export const adminHandlers = [ ) }), + rest.post("/admin/discounts/:id/conditions", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + discount: { + ...fixtures.get("discount"), + }, + }) + ) + }), + + rest.post("/admin/discounts/:id/conditions/:conditionId", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + discount: { + ...fixtures.get("discount"), + }, + }) + ) + }), + + rest.delete( + "/admin/discounts/:id/conditions/:conditionId", + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + id: req.params.conditionId, + object: "discount-condition", + deleted: true, + discount: fixtures.get("discount"), + }) + ) + } + ), + rest.get("/admin/draft-orders/", (req, res, ctx) => { return res( ctx.status(200), diff --git a/packages/medusa-react/src/hooks/admin/discounts/mutations.ts b/packages/medusa-react/src/hooks/admin/discounts/mutations.ts index 05a313eb2d..834f289a39 100644 --- a/packages/medusa-react/src/hooks/admin/discounts/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/discounts/mutations.ts @@ -1,9 +1,11 @@ import { AdminDiscountsDeleteRes, AdminDiscountsRes, + AdminPostDiscountsDiscountConditions, + AdminPostDiscountsDiscountConditionsCondition, AdminPostDiscountsDiscountDynamicCodesReq, AdminPostDiscountsDiscountReq, - AdminPostDiscountsReq, + AdminPostDiscountsReq } from "@medusajs/medusa" import { Response } from "@medusajs/medusa-js" import { useMutation, UseMutationOptions, useQueryClient } from "react-query" @@ -115,3 +117,51 @@ export const useAdminDeleteDynamicDiscountCode = ( ) ) } + +export const useAdminDiscountCreateCondition = ( + discountId: string, + options?: UseMutationOptions< + Response, + Error, + AdminPostDiscountsDiscountConditions + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + return useMutation( + (payload: AdminPostDiscountsDiscountConditions) => + client.admin.discounts.createCondition(discountId, payload), + buildOptions(queryClient, adminDiscountKeys.detail(discountId), options) + ) +} + +export const useAdminDiscountUpdateCondition = ( + discountId: string, + conditionId: string, + options?: UseMutationOptions< + Response, + Error, + AdminPostDiscountsDiscountConditionsCondition + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + return useMutation( + (payload: AdminPostDiscountsDiscountConditionsCondition) => + client.admin.discounts.updateCondition(discountId, conditionId, payload), + buildOptions(queryClient, adminDiscountKeys.detail(discountId), options) + ) +} + +export const useAdminDiscountRemoveCondition = ( + discountId: string, + options?: UseMutationOptions, Error, string> +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + return useMutation( + (conditionId: string) => + client.admin.discounts.deleteCondition(discountId, conditionId), + buildOptions(queryClient, adminDiscountKeys.detail(discountId), options) + ) +} diff --git a/packages/medusa-react/test/hooks/admin/discounts/mutations.test.ts b/packages/medusa-react/test/hooks/admin/discounts/mutations.test.ts index 3f6531f256..93fef23f70 100644 --- a/packages/medusa-react/test/hooks/admin/discounts/mutations.test.ts +++ b/packages/medusa-react/test/hooks/admin/discounts/mutations.test.ts @@ -1,3 +1,4 @@ +import { DiscountConditionOperator } from "@medusajs/medusa" import { renderHook } from "@testing-library/react-hooks" import { fixtures } from "../../../../mocks/data" import { @@ -6,7 +7,10 @@ import { useAdminDeleteDiscount, useAdminDeleteDynamicDiscountCode, useAdminDiscountAddRegion, + useAdminDiscountCreateCondition, + useAdminDiscountRemoveCondition, useAdminDiscountRemoveRegion, + useAdminDiscountUpdateCondition, useAdminUpdateDiscount, } from "../../../../src/" import { createWrapper } from "../../../utils" @@ -184,3 +188,68 @@ describe("useAdminDeleteDynamicDiscountCode hook", () => { ) }) }) + +describe("useAdminDiscountCreateCondition hook", () => { + test("Creates a condition from a discount", async () => { + const id = fixtures.get("discount").id + const conditionId = fixtures.get("discount").rule.conditions[0].id + + const { result, waitFor } = renderHook( + () => useAdminDiscountCreateCondition(id), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate({ + operator: DiscountConditionOperator.IN, + products: ["test-product"], + }) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.discount).toEqual(fixtures.get("discount")) + }) +}) +describe("useAdminDiscountUpdateCondition hook", () => { + test("Updates condition for discount", async () => { + const id = fixtures.get("discount").id + const conditionId = fixtures.get("discount").rule.conditions[0].id + + const { result, waitFor } = renderHook( + () => useAdminDiscountUpdateCondition(id, conditionId), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate({ products: ["test-product"] }) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.discount).toEqual(fixtures.get("discount")) + }) +}) + +describe("useAdminDiscountRemoveCondition hook", () => { + test("removes a condition from a discount", async () => { + const id = fixtures.get("discount").id + const conditionId = fixtures.get("discount").rule.conditions[0].id + + const { result, waitFor } = renderHook( + () => useAdminDiscountRemoveCondition(id), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate(conditionId) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.id).toEqual(conditionId) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/discounts/create-condition.ts b/packages/medusa/src/api/routes/admin/discounts/create-condition.ts new file mode 100644 index 0000000000..50ffb52e57 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/discounts/create-condition.ts @@ -0,0 +1,107 @@ +import { IsOptional, IsString } from "class-validator" +import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "." +import { Discount } from "../../../../models/discount" +import { DiscountConditionOperator } from "../../../../models/discount-condition" +import { DiscountService } from "../../../../services" +import DiscountConditionService from "../../../../services/discount-condition" +import { AdminUpsertConditionsReq } from "../../../../types/discount" +import { getRetrieveConfig } from "../../../../utils/get-query-config" +import { validator } from "../../../../utils/validator" +/** + * @oas [post] /discounts/{discount_id}/conditions + * operationId: "PostDiscountsDiscountConditions" + * summary: "Creates a DiscountCondition" + * x-authenticated: true + * parameters: + * - (path) discount_id=* {string} The id of the Product. + * - (query) expand {string} (Comma separated) Which fields should be expanded in each product of the result. + * - (query) fields {string} (Comma separated) Which fields should be included in each product of the result. + * description: "Creates a DiscountCondition" + * requestBody: + * content: + * application/json: + * schema: + * properties: + * operator: + * description: Operator of the condition + * type: string + * items: + * properties: + * products: + * type: array + * description: list of products + * product_types: + * type: array + * description: list of product types + * product_collections: + * type: array + * description: list of product collections + * product_tags: + * type: array + * description: list of product tags + * customer_groups: + * type: array + * description: list of customer_groups + * tags: + * - Discount + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * discount: + * $ref: "#/components/schemas/discount" + */ +export default async (req, res) => { + const { discount_id } = req.params + + const validatedCondition = await validator( + AdminPostDiscountsDiscountConditions, + req.body + ) + + const validatedParams = await validator( + AdminPostDiscountsDiscountConditionsParams, + req.query + ) + + const conditionService: DiscountConditionService = req.scope.resolve( + "discountConditionService" + ) + const discountService: DiscountService = req.scope.resolve("discountService") + + let discount = await discountService.retrieve(discount_id) + + await conditionService.upsertCondition({ + ...validatedCondition, + rule_id: discount.rule_id, + }) + + const config = getRetrieveConfig( + defaultAdminDiscountsFields, + defaultAdminDiscountsRelations, + validatedParams?.fields?.split(",") as (keyof Discount)[], + validatedParams?.expand?.split(",") + ) + + discount = await discountService.retrieve(discount.id, config) + + res.status(200).json({ discount }) +} + +export class AdminPostDiscountsDiscountConditions extends AdminUpsertConditionsReq { + @IsString() + operator: DiscountConditionOperator +} + +export class AdminPostDiscountsDiscountConditionsParams { + @IsString() + @IsOptional() + expand?: string + + @IsString() + @IsOptional() + fields?: string +} diff --git a/packages/medusa/src/api/routes/admin/discounts/create-discount.ts b/packages/medusa/src/api/routes/admin/discounts/create-discount.ts index d3019a54b9..9adaf530c0 100644 --- a/packages/medusa/src/api/routes/admin/discounts/create-discount.ts +++ b/packages/medusa/src/api/routes/admin/discounts/create-discount.ts @@ -83,8 +83,6 @@ import { AdminPostDiscountsDiscountParams } from "./update-discount" export default async (req, res) => { const validated = await validator(AdminPostDiscountsReq, req.body) - console.log(validated.rule.conditions) - const validatedParams = await validator( AdminPostDiscountsDiscountParams, req.query diff --git a/packages/medusa/src/api/routes/admin/discounts/delete-condition.ts b/packages/medusa/src/api/routes/admin/discounts/delete-condition.ts new file mode 100644 index 0000000000..7cfd46a7b1 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/discounts/delete-condition.ts @@ -0,0 +1,109 @@ +import { IsOptional, IsString } from "class-validator" +import { MedusaError } from "medusa-core-utils" +import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "." +import { Discount } from "../../../../models" +import { DiscountService } from "../../../../services" +import DiscountConditionService from "../../../../services/discount-condition" +import { getRetrieveConfig } from "../../../../utils/get-query-config" +import { validator } from "../../../../utils/validator" + +/** + * @oas [delete] /discounts/{discount_id}/conditions/{condition_id} + * operationId: "DeleteDiscountsDiscountConditionsCondition" + * summary: "Delete a DiscountCondition" + * description: "Deletes a DiscountCondition" + * x-authenticated: true + * parameters: + * - (path) id=* {string} The id of the Discount + * - (path) condition_id=* {string} The id of the DiscountCondition + * tags: + * - Discount + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * id: + * type: string + * description: The id of the deleted DiscountCondition + * object: + * type: string + * description: The type of the object that was deleted. + * deleted: + * type: boolean + * discount: + * type: object + * description: The Discount to which the condition used to belong + */ +export default async (req, res) => { + const { discount_id, condition_id } = req.params + + const validatedParams = await validator( + AdminDeleteDiscountsDiscountConditionsConditionParams, + req.query + ) + + const conditionService: DiscountConditionService = req.scope.resolve( + "discountConditionService" + ) + + const condition = await conditionService + .retrieve(condition_id) + .catch(() => void 0) + + if (!condition) { + // resolves idempotently in case of non-existing condition + return res.json({ + id: condition_id, + object: "discount-condition", + deleted: true, + }) + } + + const discountService: DiscountService = req.scope.resolve("discountService") + + let discount = await discountService.retrieve(discount_id, { + relations: ["rule", "rule.conditions"], + }) + + const existsOnDiscount = discount.rule.conditions.some( + (c) => c.id === condition_id + ) + + if (!existsOnDiscount) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Condition with id ${condition_id} does not belong to Discount with id ${discount_id}` + ) + } + + await conditionService.delete(condition_id) + + const config = getRetrieveConfig( + defaultAdminDiscountsFields, + defaultAdminDiscountsRelations, + validatedParams?.fields?.split(",") as (keyof Discount)[], + validatedParams?.expand?.split(",") + ) + + discount = await discountService.retrieve(discount_id, config) + + res.json({ + id: condition_id, + object: "discount-condition", + deleted: true, + discount, + }) +} + +export class AdminDeleteDiscountsDiscountConditionsConditionParams { + @IsString() + @IsOptional() + expand?: string + + @IsString() + @IsOptional() + fields?: string +} diff --git a/packages/medusa/src/api/routes/admin/discounts/index.ts b/packages/medusa/src/api/routes/admin/discounts/index.ts index 0784a1e4c0..a6f3ea5a7e 100644 --- a/packages/medusa/src/api/routes/admin/discounts/index.ts +++ b/packages/medusa/src/api/routes/admin/discounts/index.ts @@ -49,6 +49,20 @@ export default (app) => { middlewares.wrap(require("./remove-region").default) ) + // Discount condition management + route.post( + "/:discount_id/conditions/:condition_id", + middlewares.wrap(require("./update-condition").default) + ) + route.post( + "/:discount_id/conditions", + middlewares.wrap(require("./create-condition").default) + ) + route.delete( + "/:discount_id/conditions/:condition_id", + middlewares.wrap(require("./delete-condition").default) + ) + return app } @@ -88,12 +102,15 @@ export type AdminDiscountsListRes = PaginatedResponse & { } export * from "./add-region" +export * from "./create-condition" export * from "./create-discount" export * from "./create-dynamic-code" +export * from "./delete-condition" export * from "./delete-discount" export * from "./delete-dynamic-code" export * from "./get-discount" export * from "./get-discount-by-code" export * from "./list-discounts" export * from "./remove-region" +export * from "./update-condition" export * from "./update-discount" diff --git a/packages/medusa/src/api/routes/admin/discounts/update-condition.ts b/packages/medusa/src/api/routes/admin/discounts/update-condition.ts new file mode 100644 index 0000000000..22daf2d1ed --- /dev/null +++ b/packages/medusa/src/api/routes/admin/discounts/update-condition.ts @@ -0,0 +1,109 @@ +import { IsOptional, IsString } from "class-validator" +import { defaultAdminDiscountsFields, defaultAdminDiscountsRelations } from "." +import { Discount } from "../../../../models/discount" +import { DiscountService } from "../../../../services" +import DiscountConditionService from "../../../../services/discount-condition" +import { AdminUpsertConditionsReq } from "../../../../types/discount" +import { getRetrieveConfig } from "../../../../utils/get-query-config" +import { validator } from "../../../../utils/validator" +/** + * @oas [post] /discounts/{discount_id}/conditions/{condition_id} + * operationId: "PostDiscountsDiscountConditionsCondition" + * summary: "Updates a DiscountCondition" + * x-authenticated: true + * parameters: + * - (path) discount_id=* {string} The id of the Product. + * - (query) expand {string} (Comma separated) Which fields should be expanded in each product of the result. + * - (query) fields {string} (Comma separated) Which fields should be included in each product of the result. + * description: "Updates a DiscountCondition" + * requestBody: + * content: + * application/json: + * required: + * - id + * schema: + * properties: + * items: + * properties: + * products: + * type: array + * description: list of products + * product_types: + * type: array + * description: list of product types + * product_collections: + * type: array + * description: list of product collections + * product_tags: + * type: array + * description: list of product tags + * customer_groups: + * type: array + * description: list of customer_groups + * tags: + * - Discount + * responses: + * 200: + * description: OK + * content: + * application/json: + * schema: + * properties: + * discount: + * $ref: "#/components/schemas/discount" + */ + +export default async (req, res) => { + const { discount_id, condition_id } = req.params + + const validatedCondition = await validator( + AdminPostDiscountsDiscountConditionsCondition, + req.body + ) + + const validatedParams = await validator( + AdminPostDiscountsDiscountConditionsConditionParams, + req.query + ) + + const conditionService: DiscountConditionService = req.scope.resolve( + "discountConditionService" + ) + + const condition = await conditionService.retrieve(condition_id) + + const discountService: DiscountService = req.scope.resolve("discountService") + + let discount = await discountService.retrieve(discount_id) + + const updateObj = { + ...validatedCondition, + rule_id: discount.rule_id, + id: condition.id, + } + + await conditionService.upsertCondition(updateObj) + + const config = getRetrieveConfig( + defaultAdminDiscountsFields, + defaultAdminDiscountsRelations, + validatedParams?.fields?.split(",") as (keyof Discount)[], + validatedParams?.expand?.split(",") + ) + + discount = await discountService.retrieve(discount.id, config) + + res.status(200).json({ discount }) +} + +export class AdminPostDiscountsDiscountConditionsCondition extends AdminUpsertConditionsReq {} + +export class AdminPostDiscountsDiscountConditionsConditionParams { + @IsString() + @IsOptional() + expand?: string + + @IsString() + @IsOptional() + fields?: string +} diff --git a/packages/medusa/src/models/discount-condition.ts b/packages/medusa/src/models/discount-condition.ts index 4dd96be349..4b1b70ec8f 100644 --- a/packages/medusa/src/models/discount-condition.ts +++ b/packages/medusa/src/models/discount-condition.ts @@ -145,6 +145,10 @@ export class DiscountCondition { @BeforeInsert() private beforeInsert() { + if (this.id) { + return + } + const id = ulid() this.id = `discon_${id}` } diff --git a/packages/medusa/src/repositories/discount-condition.ts b/packages/medusa/src/repositories/discount-condition.ts index c969865ea6..c5618ca0b7 100644 --- a/packages/medusa/src/repositories/discount-condition.ts +++ b/packages/medusa/src/repositories/discount-condition.ts @@ -6,6 +6,7 @@ import { Not, Repository, } from "typeorm" +import { Discount } from "../models" import { DiscountCondition, DiscountConditionOperator, @@ -35,6 +36,21 @@ type DiscountConditionResourceType = EntityTarget< @EntityRepository(DiscountCondition) export class DiscountConditionRepository extends Repository { + async findOneWithDiscount( + conditionId: string, + discountId: string + ): Promise<(DiscountCondition & { discount: Discount }) | undefined> { + return (await this.createQueryBuilder("condition") + .leftJoinAndMapOne( + "condition.discount", + Discount, + "discount", + `condition.discount_rule_id = discount.rule_id and discount.id = :discId and condition.id = :dcId`, + { discId: discountId, dcId: conditionId } + ) + .getOne()) as (DiscountCondition & { discount: Discount }) | undefined + } + getJoinTableResourceIdentifiers(type: string): { joinTable: string resourceKey: string diff --git a/packages/medusa/src/repositories/order.ts b/packages/medusa/src/repositories/order.ts index 9ef6c1b9f8..ca0458602a 100644 --- a/packages/medusa/src/repositories/order.ts +++ b/packages/medusa/src/repositories/order.ts @@ -1,5 +1,5 @@ import { flatten, groupBy, map, merge } from "lodash" -import { EntityRepository, Repository, FindManyOptions } from "typeorm" +import { EntityRepository, FindManyOptions, Repository } from "typeorm" import { Order } from "../models/order" @EntityRepository(Order) diff --git a/packages/medusa/src/services/discount-condition.ts b/packages/medusa/src/services/discount-condition.ts new file mode 100644 index 0000000000..e91707b994 --- /dev/null +++ b/packages/medusa/src/services/discount-condition.ts @@ -0,0 +1,179 @@ +import { MedusaError } from "medusa-core-utils" +import { BaseService } from "medusa-interfaces" +import { EntityManager } from "typeorm" +import { EventBusService } from "." +import { DiscountCondition, DiscountConditionType } from "../models" +import { DiscountConditionRepository } from "../repositories/discount-condition" +import { FindConfig } from "../types/common" +import { UpsertDiscountConditionInput } from "../types/discount" +import { PostgresError } from "../utils/exception-formatter" + +/** + * Provides layer to manipulate discount conditions. + * @implements {BaseService} + */ +class DiscountConditionService extends BaseService { + protected readonly manager_: EntityManager + protected readonly discountConditionRepository_: typeof DiscountConditionRepository + protected readonly eventBus_: EventBusService + protected transactionManager_?: EntityManager + + constructor({ manager, discountConditionRepository, eventBusService }) { + super() + + this.manager_ = manager + this.discountConditionRepository_ = discountConditionRepository + this.eventBus_ = eventBusService + } + + withTransaction(transactionManager: EntityManager): DiscountConditionService { + if (!transactionManager) { + return this + } + + const cloned = new DiscountConditionService({ + manager: transactionManager, + discountConditionRepository: this.discountConditionRepository_, + eventBusService: this.eventBus_, + }) + + cloned.transactionManager_ = transactionManager + + return cloned + } + + async retrieve( + conditionId: string, + config?: FindConfig + ): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const conditionRepo = manager.getCustomRepository( + this.discountConditionRepository_ + ) + + const query = this.buildQuery_({ id: conditionId }, config) + + const condition = await conditionRepo.findOne(query) + + if (!condition) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `DiscountCondition with id ${conditionId} was not found` + ) + } + + return condition + }) + } + + protected static resolveConditionType_(data: UpsertDiscountConditionInput): + | { + type: DiscountConditionType + resource_ids: string[] + } + | undefined { + switch (true) { + case !!data.products?.length: + return { + type: DiscountConditionType.PRODUCTS, + resource_ids: data.products!, + } + case !!data.product_collections?.length: + return { + type: DiscountConditionType.PRODUCT_COLLECTIONS, + resource_ids: data.product_collections!, + } + case !!data.product_types?.length: + return { + type: DiscountConditionType.PRODUCT_TYPES, + resource_ids: data.product_types!, + } + case !!data.product_tags?.length: + return { + type: DiscountConditionType.PRODUCT_TAGS, + resource_ids: data.product_tags!, + } + case !!data.customer_groups?.length: + return { + type: DiscountConditionType.CUSTOMER_GROUPS, + resource_ids: data.customer_groups!, + } + default: + return undefined + } + } + + async upsertCondition(data: UpsertDiscountConditionInput): Promise { + let resolvedConditionType + + return await this.atomicPhase_( + async (manager: EntityManager) => { + resolvedConditionType = + DiscountConditionService.resolveConditionType_(data) + + if (!resolvedConditionType) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Missing one of products, collections, tags, types or customer groups in data` + ) + } + + const discountConditionRepo: DiscountConditionRepository = + manager.getCustomRepository(this.discountConditionRepository_) + + if (data.id) { + return await discountConditionRepo.addConditionResources( + data.id, + resolvedConditionType.resource_ids, + resolvedConditionType.type, + true + ) + } + + const created = discountConditionRepo.create({ + discount_rule_id: data.rule_id, + operator: data.operator, + type: resolvedConditionType.type, + }) + + const discountCondition = await discountConditionRepo.save(created) + + return await discountConditionRepo.addConditionResources( + discountCondition.id, + resolvedConditionType.resource_ids, + resolvedConditionType.type + ) + }, + async (err: { code: string }) => { + if (err.code === PostgresError.DUPLICATE_ERROR) { + // A unique key constraint failed meaning the combination of + // discount rule id, type, and operator already exists in the db. + throw new MedusaError( + MedusaError.Types.DUPLICATE_ERROR, + `Discount Condition with operator '${data.operator}' and type '${resolvedConditionType?.type}' already exist on a Discount Rule` + ) + } + } + ) + } + + async delete(discountConditionId: string): Promise { + return await this.atomicPhase_(async (manager: EntityManager) => { + const conditionRepo = manager.getCustomRepository( + this.discountConditionRepository_ + ) + + const condition = await conditionRepo.findOne({ + where: { id: discountConditionId }, + }) + + if (!condition) { + return Promise.resolve() + } + + return await conditionRepo.remove(condition) + }) + } +} + +export default DiscountConditionService diff --git a/packages/medusa/src/services/discount.ts b/packages/medusa/src/services/discount.ts index f2cf604c97..1dadeaaed1 100644 --- a/packages/medusa/src/services/discount.ts +++ b/packages/medusa/src/services/discount.ts @@ -11,7 +11,6 @@ import { } from "." import { Cart } from "../models/cart" import { Discount } from "../models/discount" -import { DiscountConditionType } from "../models/discount-condition" import { AllocationType as DiscountAllocation, DiscountRule, @@ -28,10 +27,10 @@ import { CreateDynamicDiscountInput, FilterableDiscountProps, UpdateDiscountInput, - UpsertDiscountConditionInput, } from "../types/discount" import { isFuture, isPast } from "../utils/date-helpers" -import { formatException, PostgresError } from "../utils/exception-formatter" +import { formatException } from "../utils/exception-formatter" +import DiscountConditionService from "./discount-condition" /** * Provides layer to manipulate discounts. @@ -43,6 +42,7 @@ class DiscountService extends BaseService { private discountRuleRepository_: typeof DiscountRuleRepository private giftCardRepository_: typeof GiftCardRepository private discountConditionRepository_: typeof DiscountConditionRepository + private discountConditionService_: DiscountConditionService private totalsService_: TotalsService private productService_: ProductService private regionService_: RegionService @@ -54,6 +54,7 @@ class DiscountService extends BaseService { discountRuleRepository, giftCardRepository, discountConditionRepository, + discountConditionService, totalsService, productService, regionService, @@ -77,6 +78,9 @@ class DiscountService extends BaseService { /** @private @const {DiscountConditionRepository} */ this.discountConditionRepository_ = discountConditionRepository + /** @private @const {DiscountConditionRepository} */ + this.discountConditionService_ = discountConditionService + /** @private @const {TotalsService} */ this.totalsService_ = totalsService @@ -104,6 +108,7 @@ class DiscountService extends BaseService { discountRuleRepository: this.discountRuleRepository_, giftCardRepository: this.giftCardRepository_, discountConditionRepository: this.discountConditionRepository_, + discountConditionService: this.discountConditionService_, totalsService: this.totalsService_, productService: this.productService_, regionService: this.regionService_, @@ -263,9 +268,13 @@ class DiscountService extends BaseService { const result = await discountRepo.save(created) if (conditions?.length) { - for (const cond of conditions) { - await this.upsertDiscountCondition_(result.id, cond) - } + await Promise.all( + conditions.map(async (cond) => { + await this.discountConditionService_ + .withTransaction(manager) + .upsertCondition({ rule_id: result.rule_id, ...cond }) + }) + ) } return result @@ -382,9 +391,13 @@ class DiscountService extends BaseService { } if (conditions?.length) { - for (const cond of conditions) { - await this.upsertDiscountCondition_(discount.id, cond) - } + await Promise.all( + conditions.map(async (cond) => { + await this.discountConditionService_ + .withTransaction(manager) + .upsertCondition({ rule_id: discount.rule_id, ...cond }) + }) + ) } if (regions) { @@ -573,103 +586,6 @@ class DiscountService extends BaseService { }) } - resolveConditionType_(data: UpsertDiscountConditionInput): - | { - type: DiscountConditionType - resource_ids: string[] - } - | undefined { - switch (true) { - case !!data.products?.length: - return { - type: DiscountConditionType.PRODUCTS, - resource_ids: data.products!, - } - case !!data.product_collections?.length: - return { - type: DiscountConditionType.PRODUCT_COLLECTIONS, - resource_ids: data.product_collections!, - } - case !!data.product_types?.length: - return { - type: DiscountConditionType.PRODUCT_TYPES, - resource_ids: data.product_types!, - } - case !!data.product_tags?.length: - return { - type: DiscountConditionType.PRODUCT_TAGS, - resource_ids: data.product_tags!, - } - case !!data.customer_groups?.length: - return { - type: DiscountConditionType.CUSTOMER_GROUPS, - resource_ids: data.customer_groups!, - } - default: - return undefined - } - } - - async upsertDiscountCondition_( - discountId: string, - data: UpsertDiscountConditionInput - ): Promise { - const resolvedConditionType = this.resolveConditionType_(data) - - const res = this.atomicPhase_( - async (manager) => { - const discountConditionRepo: DiscountConditionRepository = - manager.getCustomRepository(this.discountConditionRepository_) - - if (!resolvedConditionType) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Missing one of products, collections, tags, types or customer groups in data` - ) - } - - if (data.id) { - return await discountConditionRepo.addConditionResources( - data.id, - resolvedConditionType.resource_ids, - resolvedConditionType.type, - true - ) - } - - const discount = await this.retrieve(discountId, { - relations: ["rule", "rule.conditions"], - }) - - const created = discountConditionRepo.create({ - discount_rule_id: discount.rule_id, - operator: data.operator, - type: resolvedConditionType.type, - }) - - const discountCondition = await discountConditionRepo.save(created) - - return await discountConditionRepo.addConditionResources( - discountCondition.id, - resolvedConditionType.resource_ids, - resolvedConditionType.type - ) - }, - async (err: { code: string }) => { - if (err.code === PostgresError.DUPLICATE_ERROR) { - // A unique key constraint failed meaning the combination of - // discount rule id, type, and operator already exists in the db. - throw new MedusaError( - MedusaError.Types.DUPLICATE_ERROR, - `Discount Condition with operator '${data.operator}' and type '${resolvedConditionType?.type}' already exist on a Discount Rule` - ) - } - } - ) - - return res - } - async validateDiscountForProduct( discountRuleId: string, productId: string | undefined diff --git a/packages/medusa/src/services/product.js b/packages/medusa/src/services/product.js index b746e2e049..6c5a48d862 100644 --- a/packages/medusa/src/services/product.js +++ b/packages/medusa/src/services/product.js @@ -672,7 +672,7 @@ class ProductService extends BaseService { ) if (!product) { - return Promise.resolve() + return } await productRepo.softRemove(product) @@ -1069,22 +1069,25 @@ class ProductService extends BaseService { const productArray = Array.isArray(products) ? products : [products] - const priceSelectionStrategy = - this.priceSelectionStrategy_.withTransaction(manager) + const priceSelectionStrategy = this.priceSelectionStrategy_.withTransaction( + manager + ) const productsWithPrices = await Promise.all( productArray.map(async (p) => { if (p.variants?.length) { p.variants = await Promise.all( p.variants.map(async (v) => { - const prices = - await priceSelectionStrategy.calculateVariantPrice(v.id, { + const prices = await priceSelectionStrategy.calculateVariantPrice( + v.id, + { region_id: regionId, currency_code: currencyCode, cart_id: cart_id, customer_id: customer_id, include_discount_prices, - }) + } + ) return { ...v, diff --git a/packages/medusa/src/types/discount.ts b/packages/medusa/src/types/discount.ts index 0e3a2199ba..e35b045cb2 100644 --- a/packages/medusa/src/types/discount.ts +++ b/packages/medusa/src/types/discount.ts @@ -105,6 +105,7 @@ export class AdminUpsertConditionsReq { } export type UpsertDiscountConditionInput = { + rule_id?: string id?: string operator?: DiscountConditionOperator products?: string[] From ac7a17e4fcaddf656d0136b7434237a779491c3e Mon Sep 17 00:00:00 2001 From: Achufusi Ifeanyichukwu <34815628+mkaychuks@users.noreply.github.com> Date: Fri, 13 May 2022 17:09:29 +0100 Subject: [PATCH 40/92] Update deploying-on-heroku.md For your consideration, I changed "in this below example" to "in this example below" --- docs/content/how-to/deploying-on-heroku.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/content/how-to/deploying-on-heroku.md b/docs/content/how-to/deploying-on-heroku.md index 7695b94217..abdf10face 100644 --- a/docs/content/how-to/deploying-on-heroku.md +++ b/docs/content/how-to/deploying-on-heroku.md @@ -4,7 +4,7 @@ title: "Deploying on Heroku" # Deploying on Heroku -This is a guide for deploying a Medusa project on Heroku. Heroku is at PaaS that allows you to easily deploy your applications in the cloud. +This is a guide for deploying a Medusa project on Heroku. Heroku is a PaaS that allows you to easily deploy your applications in the cloud.