feat(medusa): Implement premises of order edit retrieval (#2183)

**What**
- Implements the admin/store retrieval end point 
- Service implementation of the retrieve method
- Service implementation of the computeLineItems method which aggregates the right line item based on the changes that are made
- client
  - medusa-js api
  - medusa-react queries hooks

**Tests**
- Unit tests of the retrieval end points
- Unit tests of the service retrieve method and computeLineItems
- Integration tests for admin/store
- client
  - medusa-js tests
  - medusa-react hooks tests

FIXES CORE-492
This commit is contained in:
Adrien de Peretti
2022-09-15 09:12:20 +00:00
committed by GitHub
parent 3efeb6b84f
commit f863d28b9a
36 changed files with 1317 additions and 2 deletions
+2
View File
@@ -29,6 +29,7 @@ export * from "./routes/admin/invites"
export * from "./routes/admin/notes"
export * from "./routes/admin/notifications"
export * from "./routes/admin/orders"
export * from "./routes/admin/order-edits"
export * from "./routes/admin/price-lists"
export * from "./routes/admin/product-tags"
export * from "./routes/admin/product-types"
@@ -52,6 +53,7 @@ export * from "./routes/store/collections"
export * from "./routes/store/customers"
export * from "./routes/store/gift-cards"
export * from "./routes/store/orders"
export * from "./routes/store/order-edits"
export * from "./routes/store/products"
export * from "./routes/store/regions"
export * from "./routes/store/return-reasons"
@@ -15,6 +15,7 @@ import inviteRoutes, { unauthenticatedInviteRoutes } from "./invites"
import noteRoutes from "./notes"
import notificationRoutes from "./notifications"
import orderRoutes from "./orders"
import orderEditRoutes from "./order-edits"
import priceListRoutes from "./price-lists"
import productTagRoutes from "./product-tags"
import productTypesRoutes from "./product-types"
@@ -79,6 +80,7 @@ export default (app, container, config) => {
noteRoutes(route)
notificationRoutes(route)
orderRoutes(route, featureFlagRouter)
orderEditRoutes(route, featureFlagRouter)
priceListRoutes(route, featureFlagRouter)
productRoutes(route, featureFlagRouter)
productTagRoutes(route)
@@ -0,0 +1,43 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { orderEditServiceMock } from "../../../../../services/__mocks__/order-edit"
import OrderEditingFeatureFlag from "../../../../../loaders/feature-flags/order-editing"
import {
defaultOrderEditFields,
defaultOrderEditRelations,
} from "../../../../../types/order-edit"
describe("GET /admin/order-edits/:id", () => {
describe("successfully gets an order edit", () => {
const orderEditId = IdMap.getId("testCreatedOrder")
let subject
beforeAll(async () => {
subject = await request("GET", `/admin/order-edits/${orderEditId}`, {
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
flags: [OrderEditingFeatureFlag],
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls orderService retrieve", () => {
expect(orderEditServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(orderEditServiceMock.retrieve).toHaveBeenCalledWith(orderEditId, {
select: defaultOrderEditFields,
relations: defaultOrderEditRelations,
})
expect(orderEditServiceMock.computeLineItems).toHaveBeenCalledTimes(1)
})
it("returns order", () => {
expect(subject.body.order_edit.id).toEqual(orderEditId)
})
})
})
@@ -0,0 +1,68 @@
import { Request, Response } from "express"
import { OrderEditService } from "../../../../services"
/**
* @oas [get] /order-edits/{id}
* operationId: "GetOrderEditsOrderEdit"
* summary: "Retrieve an OrderEdit"
* description: "Retrieves a OrderEdit."
* x-authenticated: true
* parameters:
* - (path) id=* {string} The ID of the OrderEdit.
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* // must be previously logged in or use api token
* medusa.admin.orderEdit.retrieve(orderEditId)
* .then(({ order_edit }) => {
* console.log(order_edit.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request GET 'https://medusa-url.com/admin/order-edits/{id}' \
* --header 'Authorization: Bearer {api_token}'
* security:
* - api_token: []
* - cookie_auth: []
* tags:
* - OrderEdit
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* order_edit:
* $ref: "#/components/schemas/order_edit"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const orderEditService: OrderEditService =
req.scope.resolve("orderEditService")
const { id } = req.params
const retrieveConfig = req.retrieveConfig
const orderEdit = await orderEditService.retrieve(id, retrieveConfig)
const { items, removedItems } = await orderEditService.computeLineItems(id)
orderEdit.items = items
orderEdit.removed_items = removedItems
return res.json({ order_edit: orderEdit })
}
@@ -0,0 +1,36 @@
import { Router } from "express"
import middlewares, { transformQuery } from "../../../middlewares"
import { EmptyQueryParams } from "../../../../types/common"
import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled"
import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing"
import {
defaultOrderEditFields,
defaultOrderEditRelations,
} from "../../../../types/order-edit"
import { OrderEdit } from "../../../../models"
const route = Router()
export default (app) => {
app.use(
"/order-edits",
isFeatureFlagEnabled(OrderEditingFeatureFlag.key),
route
)
route.get(
"/:id",
transformQuery(EmptyQueryParams, {
defaultRelations: defaultOrderEditRelations,
defaultFields: defaultOrderEditFields,
isList: false,
}),
middlewares.wrap(require("./get-order-edit").default)
)
return app
}
export type AdminOrdersEditsRes = {
order_edit: OrderEdit
}
@@ -7,6 +7,7 @@ import collectionRoutes from "./collections"
import customerRoutes from "./customers"
import giftCardRoutes from "./gift-cards"
import orderRoutes from "./orders"
import orderEditRoutes from "./order-edits"
import productRoutes from "./products"
import regionRoutes from "./regions"
import returnReasonRoutes from "./return-reasons"
@@ -35,6 +36,7 @@ export default (app, container, config) => {
customerRoutes(route, container)
productRoutes(route)
orderRoutes(route)
orderEditRoutes(route)
cartRoutes(route, container)
shippingOptionRoutes(route)
regionRoutes(route)
@@ -0,0 +1,43 @@
import { IdMap } from "medusa-test-utils"
import { request } from "../../../../../helpers/test-request"
import { orderEditServiceMock } from "../../../../../services/__mocks__/order-edit"
import OrderEditingFeatureFlag from "../../../../../loaders/feature-flags/order-editing"
import {
defaultOrderEditFields,
defaultOrderEditRelations,
} from "../../../../../types/order-edit"
import { storeOrderEditNotAllowedFields } from "../index"
describe("GET /store/order-edits/:id", () => {
describe("successfully gets an order edit", () => {
const orderEditId = IdMap.getId("testCreatedOrder")
let subject
beforeAll(async () => {
subject = await request("GET", `/store/order-edits/${orderEditId}`, {
flags: [OrderEditingFeatureFlag],
})
})
afterAll(() => {
jest.clearAllMocks()
})
it("calls orderService retrieve", () => {
expect(orderEditServiceMock.retrieve).toHaveBeenCalledTimes(1)
expect(orderEditServiceMock.retrieve).toHaveBeenCalledWith(orderEditId, {
select: defaultOrderEditFields.filter(
(field) => !storeOrderEditNotAllowedFields.includes(field)
),
relations: defaultOrderEditRelations.filter(
(field) => !storeOrderEditNotAllowedFields.includes(field)
),
})
expect(orderEditServiceMock.computeLineItems).toHaveBeenCalledTimes(1)
})
it("returns order", () => {
expect(subject.body.order_edit.id).toEqual(orderEditId)
})
})
})
@@ -0,0 +1,62 @@
import { Request, Response } from "express"
import { OrderEditService } from "../../../../services"
/**
* @oas [get] /order-edits/{id}
* operationId: "GetOrderEditsOrderEdit"
* summary: "Retrieve an OrderEdit"
* description: "Retrieves a OrderEdit."
* parameters:
* - (path) id=* {string} The ID of the OrderEdit.
* x-codeSamples:
* - lang: JavaScript
* label: JS Client
* source: |
* import Medusa from "@medusajs/medusa-js"
* const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })
* medusa.orderEdit.retrieve(orderEditId)
* .then(({ order_edit }) => {
* console.log(order_edit.id);
* });
* - lang: Shell
* label: cURL
* source: |
* curl --location --request GET 'https://medusa-url.com/store/order-edits/{id}'
* tags:
* - OrderEdit
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* properties:
* order_edit:
* $ref: "#/components/schemas/order_edit"
* "400":
* $ref: "#/components/responses/400_error"
* "401":
* $ref: "#/components/responses/unauthorized"
* "404":
* $ref: "#/components/responses/not_found_error"
* "409":
* $ref: "#/components/responses/invalid_state_error"
* "422":
* $ref: "#/components/responses/invalid_request_error"
* "500":
* $ref: "#/components/responses/500_error"
*/
export default async (req: Request, res: Response) => {
const orderEditService: OrderEditService =
req.scope.resolve("orderEditService")
const { id } = req.params
const retrieveConfig = req.retrieveConfig
const orderEdit = await orderEditService.retrieve(id, retrieveConfig)
const { items, removedItems } = await orderEditService.computeLineItems(id)
orderEdit.items = items
orderEdit.removed_items = removedItems
return res.json({ order_edit: orderEdit })
}
@@ -0,0 +1,51 @@
import { Router } from "express"
import middlewares, { transformQuery } from "../../../middlewares"
import { EmptyQueryParams } from "../../../../types/common"
import { isFeatureFlagEnabled } from "../../../middlewares/feature-flag-enabled"
import OrderEditingFeatureFlag from "../../../../loaders/feature-flags/order-editing"
import {
defaultOrderEditFields,
defaultOrderEditRelations,
} from "../../../../types/order-edit"
import { OrderEdit } from "../../../../models"
const route = Router()
export default (app) => {
app.use(
"/order-edits",
isFeatureFlagEnabled(OrderEditingFeatureFlag.key),
route
)
route.get(
"/:id",
transformQuery(EmptyQueryParams, {
defaultRelations: defaultOrderEditRelations.filter(
(field) => !storeOrderEditNotAllowedFields.includes(field)
),
defaultFields: defaultOrderEditFields.filter(
(field) => !storeOrderEditNotAllowedFields.includes(field)
),
allowedFields: defaultOrderEditFields,
isList: false,
}),
middlewares.wrap(require("./get-order-edit").default)
)
return app
}
export type StoreOrderEditsRes = {
order_edit: Omit<
OrderEdit,
"internal_note" | "created_by" | "confirmed_by" | "canceled_by"
>
}
export const storeOrderEditNotAllowedFields = [
"internal_note",
"created_by",
"confirmed_by",
"canceled_by",
]
+2
View File
@@ -35,6 +35,8 @@ export * from "./note"
export * from "./notification"
export * from "./oauth"
export * from "./order"
export * from "./order-edit"
export * from "./order-item-change"
export * from "./payment"
export * from "./payment-provider"
export * from "./payment-session"
+6
View File
@@ -64,6 +64,7 @@ export class OrderEdit extends SoftDeletableEntity {
difference_due: number
items: LineItem[]
removed_items: LineItem[]
@BeforeInsert()
private beforeInsert(): void {
@@ -154,4 +155,9 @@ export class OrderEdit extends SoftDeletableEntity {
* description: Computed line items from the changes.
* items:
* $ref: "#/components/schemas/line_item"
* removed_items:
* type: array
* description: Computed line items from the changes that have been marked as deleted.
* removed_items:
* $ref: "#/components/schemas/line_item"
*/
+64 -2
View File
@@ -1,6 +1,68 @@
import { EntityRepository, Repository } from "typeorm"
import { EntityRepository, FindManyOptions, Repository } from "typeorm"
import { OrderEdit } from "../models/order-edit"
import { flatten, groupBy, merge } from "lodash"
@EntityRepository(OrderEdit)
export class OrderEditRepository extends Repository<OrderEdit> {}
export class OrderEditRepository extends Repository<OrderEdit> {
public async findWithRelations(
relations: (keyof OrderEdit | string)[] = [],
idsOrOptionsWithoutRelations:
| Omit<FindManyOptions<OrderEdit>, "relations">
| string[] = {}
): Promise<[OrderEdit[], number]> {
let entities: OrderEdit[] = []
let count
if (Array.isArray(idsOrOptionsWithoutRelations)) {
entities = await this.findByIds(idsOrOptionsWithoutRelations)
count = idsOrOptionsWithoutRelations.length
} else {
const [results, resultCount] = await this.findAndCount(
idsOrOptionsWithoutRelations
)
entities = results
count = resultCount
}
const entitiesIds = entities.map(({ id }) => id)
const groupedRelations = {}
for (const rel of relations) {
const [topLevel] = rel.split(".")
if (groupedRelations[topLevel]) {
groupedRelations[topLevel].push(rel)
} else {
groupedRelations[topLevel] = [rel]
}
}
const entitiesIdsWithRelations = await Promise.all(
Object.entries(groupedRelations).map(async ([_, rels]) => {
return this.findByIds(entitiesIds, {
select: ["id"],
relations: rels as string[],
})
})
).then(flatten)
const entitiesAndRelations = entitiesIdsWithRelations.concat(entities)
const entitiesAndRelationsById = groupBy(entitiesAndRelations, "id")
return [
Object.values(entitiesAndRelationsById).map((v) => merge({}, ...v)),
count,
]
}
public async findOneWithRelations(
relations: Array<keyof OrderEdit> = [],
optionsWithoutRelations: Omit<FindManyOptions<OrderEdit>, "relations"> = {}
): Promise<OrderEdit> {
// Limit 1
optionsWithoutRelations.take = 1
const [result] = await this.findWithRelations(
relations,
optionsWithoutRelations
)
return result[0]
}
}
@@ -0,0 +1,41 @@
import { IdMap } from "medusa-test-utils"
export const orderEdits = {
testCreatedOrder: {
id: IdMap.getId("testCreatedOrder"),
order_id: "empty-id",
internal_note: "internal note",
declined_reason: null,
declined_at: null,
declined_by: null,
canceled_at: null,
canceled_by: null,
requested_at: null,
requested_by: null,
created_at: new Date(),
created_by: "admin_user",
confirmed_at: null,
confirmed_by: null,
},
}
export const orderEditServiceMock = {
withTransaction: function () {
return this
},
retrieve: jest.fn().mockImplementation((orderId) => {
if (orderId === IdMap.getId("testCreatedOrder")) {
return Promise.resolve(orderEdits.testCreatedOrder)
}
return Promise.resolve(undefined)
}),
computeLineItems: jest.fn().mockImplementation((orderEdit) => {
return Promise.resolve(orderEdit)
}),
}
const mock = jest.fn().mockImplementation(() => {
return orderEditServiceMock
})
export default mock
@@ -0,0 +1,107 @@
import { IdMap, MockManager, MockRepository } from "medusa-test-utils"
import { OrderEditService, OrderService } from "../index"
import { OrderEditItemChangeType } from "../../models"
import { OrderServiceMock } from "../__mocks__/order"
const orderEditWithChanges = {
id: IdMap.getId("order-edit-with-changes"),
order: {
id: IdMap.getId("order-edit-with-changes-order"),
items: [
{
id: IdMap.getId("line-item-1"),
},
{
id: IdMap.getId("line-item-2"),
},
],
},
changes: [
{
type: OrderEditItemChangeType.ITEM_REMOVE,
id: "order-edit-with-changes-removed-change",
original_line_item_id: IdMap.getId("line-item-1"),
original_line_item: {
id: IdMap.getId("line-item-1"),
},
},
{
type: OrderEditItemChangeType.ITEM_ADD,
id: IdMap.getId("order-edit-with-changes-added-change"),
line_item_id: IdMap.getId("line-item-3"),
line_item: {
id: IdMap.getId("line-item-3"),
},
},
{
type: OrderEditItemChangeType.ITEM_UPDATE,
id: IdMap.getId("order-edit-with-changes-updated-change"),
original_line_item_id: IdMap.getId("line-item-2"),
original_line_item: {
id: IdMap.getId("line-item-2"),
},
line_item_id: IdMap.getId("line-item-4"),
line_item: {
id: IdMap.getId("line-item-4"),
},
},
],
}
describe("OrderEditService", () => {
const orderEditRepository = MockRepository({
findOneWithRelations: (relations, query) => {
if (query?.where?.id === IdMap.getId("order-edit-with-changes")) {
return orderEditWithChanges
}
return {}
},
})
const orderEditService = new OrderEditService({
manager: MockManager,
orderEditRepository,
orderService: OrderServiceMock as unknown as OrderService,
})
it("should retrieve an order edit and call the repository with the right arguments", async () => {
await orderEditService.retrieve(IdMap.getId("order-edit-with-changes"))
expect(orderEditRepository.findOneWithRelations).toHaveBeenCalledTimes(1)
expect(orderEditRepository.findOneWithRelations).toHaveBeenCalledWith(
undefined,
{
where: { id: IdMap.getId("order-edit-with-changes") },
}
)
})
it("should compute the items from the changes and attach them to the orderEdit", async () => {
const orderEdit = await orderEditService.retrieve(
IdMap.getId("order-edit-with-changes")
)
const { items, removedItems } = await orderEditService.computeLineItems(
orderEdit.id
)
expect(items.length).toBe(2)
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: IdMap.getId("line-item-2"),
}),
expect.objectContaining({
id: IdMap.getId("line-item-3"),
}),
])
)
expect(removedItems.length).toBe(1)
expect(removedItems).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: IdMap.getId("line-item-1"),
}),
])
)
})
})
+1
View File
@@ -21,6 +21,7 @@ export { default as NoteService } from "./note"
export { default as NotificationService } from "./notification"
export { default as OauthService } from "./oauth"
export { default as OrderService } from "./order"
export { default as OrderEditService } from "./order-edit"
export { default as PaymentProviderService } from "./payment-provider"
export { default as PricingService } from "./pricing"
export { default as ProductCollectionService } from "./product-collection"
+117
View File
@@ -0,0 +1,117 @@
import { EntityManager } from "typeorm"
import { FindConfig } from "../types/common"
import { buildQuery } from "../utils"
import { MedusaError } from "medusa-core-utils"
import { OrderEditRepository } from "../repositories/order-edit"
import {
LineItem,
OrderEdit,
OrderEditItemChangeType,
OrderItemChange,
} from "../models"
import { TransactionBaseService } from "../interfaces"
import { OrderService } from "./index"
type InjectedDependencies = {
manager: EntityManager
orderEditRepository: typeof OrderEditRepository
orderService: OrderService
}
export default class OrderEditService extends TransactionBaseService {
protected transactionManager_: EntityManager | undefined
protected readonly manager_: EntityManager
protected readonly orderEditRepository_: typeof OrderEditRepository
protected readonly orderService_: OrderService
constructor({
manager,
orderEditRepository,
orderService,
}: InjectedDependencies) {
// eslint-disable-next-line prefer-rest-params
super(arguments[0])
this.manager_ = manager
this.orderEditRepository_ = orderEditRepository
this.orderService_ = orderService
}
async retrieve(
orderEditId: string,
config: FindConfig<OrderEdit> = {}
): Promise<OrderEdit | never> {
const orderEditRepository = this.manager_.getCustomRepository(
this.orderEditRepository_
)
const { relations, ...query } = buildQuery({ id: orderEditId }, config)
const orderEdit = await orderEditRepository.findOneWithRelations(
relations as (keyof OrderEdit)[],
query
)
if (!orderEdit) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Order edit with id ${orderEditId} was not found`
)
}
return orderEdit
}
async computeLineItems(
orderEditId: string
): Promise<{ items: LineItem[]; removedItems: LineItem[] }> {
const orderEdit = await this.retrieve(orderEditId, {
select: ["id", "order_id", "changes", "order"],
relations: [
"changes",
"changes.line_item",
"changes.original_line_item",
"order",
"order.items",
],
})
const originalItems = orderEdit.order.items
const removedItems: LineItem[] = []
const items: LineItem[] = []
const updatedItems = orderEdit.changes
.map((itemChange) => {
if (itemChange.type === OrderEditItemChangeType.ITEM_ADD) {
items.push(itemChange.line_item as LineItem)
return
}
if (itemChange.type === OrderEditItemChangeType.ITEM_REMOVE) {
removedItems.push({
...itemChange.original_line_item,
id: itemChange.original_line_item_id,
} as LineItem)
return
}
return [itemChange.original_line_item_id as string, itemChange]
})
.filter((change) => !!change) as [string, OrderItemChange][]
const orderEditUpdatedChangesMap: Map<string, OrderItemChange> = new Map(
updatedItems
)
originalItems.map((item) => {
const itemChange = orderEditUpdatedChangesMap.get(item.id)
if (itemChange) {
items.push({
...itemChange.line_item,
id: itemChange.original_line_item_id,
} as LineItem)
}
})
return { items, removedItems }
}
}
+24
View File
@@ -0,0 +1,24 @@
import { OrderEdit } from "../models"
export const defaultOrderEditRelations: string[] = [
"changes",
"changes.line_item",
"changes.original_line_item",
]
export const defaultOrderEditFields: (keyof OrderEdit)[] = [
"id",
"changes",
"order_id",
"created_by",
"requested_by",
"requested_at",
"confirmed_by",
"confirmed_at",
"declined_by",
"declined_reason",
"declined_at",
"canceled_by",
"canceled_at",
"internal_note",
]
@@ -145,6 +145,17 @@ export function prepareRetrieveQuery<
expandFields = fields.split(",") as (keyof TEntity)[]
}
if (queryConfig?.allowedFields?.length) {
expandFields?.forEach((field) => {
if (!queryConfig?.allowedFields?.includes(field as string)) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Field ${field.toString()} is not valid`
)
}
})
}
return getRetrieveConfig<TEntity>(
queryConfig?.defaultFields as (keyof TEntity)[],
(queryConfig?.defaultRelations ?? []) as string[],