diff --git a/.changeset/tiny-onions-watch.md b/.changeset/tiny-onions-watch.md new file mode 100644 index 0000000000..dd56e3e521 --- /dev/null +++ b/.changeset/tiny-onions-watch.md @@ -0,0 +1,7 @@ +--- +"@medusajs/core-flows": patch +"@medusajs/medusa": patch +"@medusajs/types": patch +--- + +feat(core-flows, medusa, types): add reservation item endpoints diff --git a/integration-tests/api/__tests__/admin/reservations/index.spec.ts b/integration-tests/api/__tests__/admin/reservations/index.spec.ts new file mode 100644 index 0000000000..6ace323dd8 --- /dev/null +++ b/integration-tests/api/__tests__/admin/reservations/index.spec.ts @@ -0,0 +1,589 @@ +import { + adminHeaders, + createAdminUser, +} from "../../../../helpers/create-admin-user" + +import { IInventoryServiceNext } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { breaking } from "../../../../helpers/breaking" +import { medusaIntegrationTestRunner } from "medusa-test-utils" + +medusaIntegrationTestRunner({ + env: { + MEDUSA_FF_MEDUSA_V2: true, + }, + testSuite: ({ dbConnection, getContainer, api }) => { + let appContainer + let service: IInventoryServiceNext + + beforeEach(async () => { + appContainer = getContainer() + + await createAdminUser(dbConnection, adminHeaders, appContainer) + + service = appContainer.resolve(ModuleRegistrationName.INVENTORY) + }) + + describe("Reservation items", () => { + it.skip("Create reservation item throws if available item quantity is less than reservation quantity", async () => { + // const orderRes = await api.get( + // `/admin/orders/${order.id}`, + // adminHeaders + // ) + // expect(orderRes.data.order.items[0].quantity).toBe(2) + // expect(orderRes.data.order.items[0].fulfilled_quantity).toBeFalsy() + // const payload = { + // quantity: 1, + // inventory_item_id: inventoryItem.id, + // line_item_id: lineItemId, + // location_id: locationId, + // } + // const res = await api + // .post(`/admin/reservations`, payload, adminHeaders) + // .catch((err) => err) + // expect(res.response.status).toBe(400) + // expect(res.response.data).toEqual({ + // type: "invalid_data", + // message: + // "The reservation quantity cannot be greater than the unfulfilled line item quantity", + // }) + }) + + it.skip("Update reservation item throws if available item quantity is less than reservation quantity", async () => { + // const orderRes = await api.get( + // `/admin/orders/${order.id}`, + // adminHeaders + // ) + // expect(orderRes.data.order.items[0].quantity).toBe(2) + // expect(orderRes.data.order.items[0].fulfilled_quantity).toBeFalsy() + // const payload = { + // quantity: 3, + // } + // const res = await api + // .post( + // `/admin/reservations/${reservationItem.id}`, + // payload, + // adminHeaders + // ) + // .catch((err) => err) + // expect(res.response.status).toBe(400) + // expect(res.response.data).toEqual({ + // type: "invalid_data", + // message: + // "The reservation quantity cannot be greater than the unfulfilled line item quantity", + // }) + }) + + describe("Create reservation item", () => { + let invItemId + let location + + beforeEach(async () => { + await breaking(null, async () => { + const stockRes = await api.post( + `/admin/stock-locations`, + { + name: "Fake Warehouse 1", + }, + adminHeaders + ) + + location = stockRes.data.stock_location.id + + const inventoryItemResponse = await api.post( + `/admin/inventory-items`, + { + sku: "12345", + }, + adminHeaders + ) + + invItemId = inventoryItemResponse.data.inventory_item.id + + await api.post( + `/admin/inventory-items/${invItemId}/location-levels`, + { + location_id: location, + stocked_quantity: 100, + }, + adminHeaders + ) + }) + }) + + it("should create a reservation item", async () => { + await breaking(null, async () => { + const reservationResponse = await api.post( + `/admin/reservations`, + { + line_item_id: "line-item-id-1", + inventory_item_id: invItemId, + location_id: location, + description: "test description", + quantity: 1, + }, + adminHeaders + ) + + expect(reservationResponse.status).toEqual(200) + expect(reservationResponse.data.reservation).toEqual( + expect.objectContaining({ + line_item_id: "line-item-id-1", + inventory_item_id: invItemId, + location_id: location, + description: "test description", + quantity: 1, + }) + ) + }) + }) + }) + + describe("Update reservation item", () => { + let invItemId + let location + let reservationId + + beforeEach(async () => { + await breaking(null, async () => { + const stockRes = await api.post( + `/admin/stock-locations`, + { + name: "Fake Warehouse 1", + }, + adminHeaders + ) + + location = stockRes.data.stock_location.id + + const inventoryItemResponse = await api.post( + `/admin/inventory-items`, + { + sku: "12345", + }, + adminHeaders + ) + + invItemId = inventoryItemResponse.data.inventory_item.id + + await api.post( + `/admin/inventory-items/${invItemId}/location-levels`, + { + location_id: location, + stocked_quantity: 100, + }, + adminHeaders + ) + + const reservationResponse = await api.post( + `/admin/reservations`, + { + line_item_id: "line-item-id-1", + inventory_item_id: invItemId, + location_id: location, + description: "test description", + quantity: 1, + }, + adminHeaders + ) + reservationId = reservationResponse.data.reservation.id + }) + }) + + it("should update a reservation item", async () => { + await breaking(null, async () => { + const reservationResponse = await api.post( + `/admin/reservations/${reservationId}`, + { + quantity: 3, + }, + adminHeaders + ) + + expect(reservationResponse.status).toEqual(200) + expect(reservationResponse.data.reservation).toEqual( + expect.objectContaining({ + quantity: 3, + }) + ) + }) + }) + }) + + describe("Delete reservation item", () => { + let invItemId + let location + let reservationId + + beforeEach(async () => { + await breaking(null, async () => { + const stockRes = await api.post( + `/admin/stock-locations`, + { + name: "Fake Warehouse 1", + }, + adminHeaders + ) + + location = stockRes.data.stock_location.id + + const inventoryItemResponse = await api.post( + `/admin/inventory-items`, + { + sku: "12345", + }, + adminHeaders + ) + + invItemId = inventoryItemResponse.data.inventory_item.id + + await api.post( + `/admin/inventory-items/${invItemId}/location-levels`, + { + location_id: location, + stocked_quantity: 100, + }, + adminHeaders + ) + + const reservationResponse = await api.post( + `/admin/reservations`, + { + line_item_id: "line-item-id-1", + inventory_item_id: invItemId, + location_id: location, + description: "test description", + quantity: 1, + }, + adminHeaders + ) + reservationId = reservationResponse.data.reservation.id + }) + }) + + it("should update a reservation item", async () => { + await breaking(null, async () => { + const reservationResponse = await api.delete( + `/admin/reservations/${reservationId}`, + adminHeaders + ) + + expect(reservationResponse.status).toEqual(200) + expect(reservationResponse.data).toEqual( + expect.objectContaining({ + id: reservationId, + object: "reservation", + deleted: true, + }) + ) + + let error + await api + .get(`/admin/reservations/${reservationId}`, adminHeaders) + .catch((err) => { + error = err + }) + + expect(error.response.status).toBe(404) + }) + }) + }) + + describe("List reservation items", () => { + let invItemId + let invItemId2 + let location + let reservation + let reservation2 + let scId + + beforeEach(async () => { + await breaking(null, async () => { + const stockRes = await api.post( + `/admin/stock-locations`, + { + name: "Fake Warehouse 2", + }, + adminHeaders + ) + + location = stockRes.data.stock_location.id + + const scResponse = await api.post( + `/admin/sales-channels`, + { name: "test" }, + adminHeaders + ) + + scId = scResponse.data.sales_channel.id + + await api.post( + `/admin/stock-locations/${location}/sales-channels/batch/add`, + { + sales_channel_ids: [scId], + }, + adminHeaders + ) + + const inventoryItemResponse = await api.post( + `/admin/inventory-items`, + { + sku: "12345", + }, + adminHeaders + ) + + invItemId = inventoryItemResponse.data.inventory_item.id + + const inventoryItemResponse2 = await api.post( + `/admin/inventory-items`, + { + sku: "67890", + }, + adminHeaders + ) + + invItemId2 = inventoryItemResponse2.data.inventory_item.id + + await api.post( + `/admin/inventory-items/${invItemId}/location-levels`, + { + location_id: location, + stocked_quantity: 100, + }, + adminHeaders + ) + + await api.post( + `/admin/inventory-items/${invItemId2}/location-levels`, + { + location_id: location, + stocked_quantity: 100, + }, + adminHeaders + ) + + const reservationResponse = await api.post( + `/admin/reservations`, + { + line_item_id: "line-item-id-1", + inventory_item_id: invItemId, + location_id: location, + quantity: 2, + }, + adminHeaders + ) + + reservation = reservationResponse.data.reservation + + const reservationResponse2 = await api.post( + `/admin/reservations`, + { + line_item_id: "line-item-id-2", + inventory_item_id: invItemId2, + location_id: location, + description: "test description", + quantity: 1, + }, + adminHeaders + ) + + reservation2 = reservationResponse2.data.reservation + }) + }) + + it("lists reservation items", async () => { + await breaking(null, async () => { + const reservationsRes = await api + .get(`/admin/reservations`, adminHeaders) + .catch(console.warn) + expect(reservationsRes.data.reservations.length).toBe(2) + expect(reservationsRes.data.reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: reservation.id, + }), + expect.objectContaining({ + id: reservation2.id, + }), + ]) + ) + }) + }) + + describe("Filters reservation items", () => { + it("filters by location", async () => { + await breaking(null, async () => { + const reservationsRes = await api.get( + `/admin/reservations?location_id[]=${location}`, + adminHeaders + ) + expect(reservationsRes.data.reservations.length).toBe(2) + expect(reservationsRes.data.reservations[0].location_id).toBe( + location + ) + }) + }) + + it("filters by itemID", async () => { + await breaking(null, async () => { + const reservationsRes = await api.get( + `/admin/reservations?inventory_item_id[]=${invItemId}`, + adminHeaders + ) + expect(reservationsRes.data.reservations.length).toBe(1) + expect( + reservationsRes.data.reservations[0].inventory_item_id + ).toBe(invItemId) + }) + }) + + it("filters by quantity", async () => { + await breaking(null, async () => { + const reservationsRes = await api.get( + `/admin/reservations?quantity[$gt]=1`, + adminHeaders + ) + + expect(reservationsRes.data.reservations.length).toBe(1) + expect(reservationsRes.data.reservations[0].id).toBe( + reservation.id + ) + }) + }) + + it("filters by date", async () => { + await breaking(null, async () => { + const reservationsRes = await api.get( + `/admin/reservations?created_at[$gte]=${new Date( + reservation2.created_at + ).toISOString()}`, + adminHeaders + ) + + expect(reservationsRes.data.reservations.length).toBe(1) + expect(reservationsRes.data.reservations[0].id).toBe( + reservation2.id + ) + }) + }) + + it("filters by description using equals", async () => { + await breaking(null, async () => { + const reservationsRes = await api + .get( + `/admin/reservations?description=test%20description`, + adminHeaders + ) + .catch(console.warn) + + expect(reservationsRes.data.reservations.length).toBe(1) + expect(reservationsRes.data.reservations[0].id).toBe( + reservation2.id + ) + }) + }) + + it("filters by description using equals removes results", async () => { + await breaking(null, async () => { + const reservationsRes = await api.get( + `/admin/reservations?description=description`, + adminHeaders + ) + + expect(reservationsRes.data.reservations.length).toBe(0) + }) + }) + + it("filters by description using contains", async () => { + await breaking(null, async () => { + const reservationsRes = await api.get( + `/admin/reservations?description[$ilike]=%descri%`, + adminHeaders + ) + + expect(reservationsRes.data.reservations.length).toBe(1) + expect(reservationsRes.data.reservations[0].id).toBe( + reservation2.id + ) + }) + }) + + it("filters by description using starts_with", async () => { + await breaking(null, async () => { + const reservationsRes = await api + .get( + `/admin/reservations?description[$ilike]=test%`, + adminHeaders + ) + .catch(console.log) + + expect(reservationsRes.data.reservations.length).toBe(1) + expect(reservationsRes.data.reservations[0].id).toBe( + reservation2.id + ) + }) + }) + + it("filters by description using starts_with removes results", async () => { + await breaking(null, async () => { + const reservationsRes = await api.get( + `/admin/reservations?description[$ilike]=description%`, + adminHeaders + ) + + expect(reservationsRes.data.reservations.length).toBe(0) + }) + }) + + it("filters by description using ends_with", async () => { + await breaking(null, async () => { + const reservationsRes = await api.get( + `/admin/reservations?description[$ilike]=%test`, + adminHeaders + ) + + expect(reservationsRes.data.reservations.length).toBe(0) + }) + }) + + it("filters by description using ends_with removes results", async () => { + await breaking(null, async () => { + const reservationsRes = await api.get( + `/admin/reservations?description[$ilike]=%description`, + adminHeaders + ) + + expect(reservationsRes.data.reservations.length).toBe(1) + expect(reservationsRes.data.reservations[0].id).toBe( + reservation2.id + ) + }) + }) + }) + }) + + it.skip("lists reservations with inventory_items and line items", async () => { + await breaking(null, async () => { + const res = await api.get( + `/admin/reservations?expand=line_item,inventory_item`, + adminHeaders + ) + + expect(res.status).toEqual(200) + expect(res.data.reservations.length).toEqual(1) + expect(res.data.reservations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + inventory_item: expect.objectContaining({}), + line_item: expect.objectContaining({ + order: expect.objectContaining({}), + }), + }), + ]) + ) + }) + }) + }) + }, +}) diff --git a/integration-tests/api/medusa-config.js b/integration-tests/api/medusa-config.js index 6cd153c227..1aee8b9719 100644 --- a/integration-tests/api/medusa-config.js +++ b/integration-tests/api/medusa-config.js @@ -71,6 +71,10 @@ module.exports = { resolve: "@medusajs/stock-location-next", options: {}, }, + [Modules.INVENTORY]: { + resolve: "@medusajs/inventory-next", + options: {}, + }, [Modules.PRODUCT]: true, [Modules.PRICING]: true, [Modules.PROMOTION]: true, diff --git a/packages/core-flows/src/index.ts b/packages/core-flows/src/index.ts index f72bcf928f..6b1193f347 100644 --- a/packages/core-flows/src/index.ts +++ b/packages/core-flows/src/index.ts @@ -16,6 +16,7 @@ export * from "./price-list" export * from "./pricing" export * from "./product" export * from "./promotion" +export * from "./reservation" export * from "./region" export * from "./sales-channel" export * from "./shipping-options" diff --git a/packages/core-flows/src/reservation/index.ts b/packages/core-flows/src/reservation/index.ts new file mode 100644 index 0000000000..68de82c9f9 --- /dev/null +++ b/packages/core-flows/src/reservation/index.ts @@ -0,0 +1,2 @@ +export * from "./steps" +export * from "./workflows" diff --git a/packages/core-flows/src/reservation/steps/create-reservations.ts b/packages/core-flows/src/reservation/steps/create-reservations.ts new file mode 100644 index 0000000000..fcd103cc9d --- /dev/null +++ b/packages/core-flows/src/reservation/steps/create-reservations.ts @@ -0,0 +1,32 @@ +import { IInventoryServiceNext, InventoryNext } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const createReservationsStepId = "create-reservations-step" +export const createReservationsStep = createStep( + createReservationsStepId, + async (data: InventoryNext.CreateReservationItemInput[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.INVENTORY + ) + + const created = await service.createReservationItems(data) + + return new StepResponse( + created, + created.map((reservation) => reservation.id) + ) + }, + async (createdIds, { container }) => { + if (!createdIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.INVENTORY + ) + + await service.deleteReservationItems(createdIds) + } +) diff --git a/packages/core-flows/src/reservation/steps/delete-reservations.ts b/packages/core-flows/src/reservation/steps/delete-reservations.ts new file mode 100644 index 0000000000..e0386b264a --- /dev/null +++ b/packages/core-flows/src/reservation/steps/delete-reservations.ts @@ -0,0 +1,29 @@ +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +import { IInventoryServiceNext } from "@medusajs/types" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const deleteReservationsStepId = "delete-reservations" +export const deleteReservationsStep = createStep( + deleteReservationsStepId, + async (ids: string[], { container }) => { + const service = container.resolve( + ModuleRegistrationName.INVENTORY + ) + + await service.softDeleteReservationItems(ids) + + return new StepResponse(void 0, ids) + }, + async (prevIds, { container }) => { + if (!prevIds?.length) { + return + } + + const service = container.resolve( + ModuleRegistrationName.INVENTORY + ) + + await service.restoreReservationItems(prevIds) + } +) diff --git a/packages/core-flows/src/reservation/steps/index.ts b/packages/core-flows/src/reservation/steps/index.ts new file mode 100644 index 0000000000..dca3839772 --- /dev/null +++ b/packages/core-flows/src/reservation/steps/index.ts @@ -0,0 +1,3 @@ +export * from "./create-reservations" +export * from "./delete-reservations" +export * from "./update-reservations" diff --git a/packages/core-flows/src/reservation/steps/update-reservations.ts b/packages/core-flows/src/reservation/steps/update-reservations.ts new file mode 100644 index 0000000000..467c51395a --- /dev/null +++ b/packages/core-flows/src/reservation/steps/update-reservations.ts @@ -0,0 +1,54 @@ +import { + IInventoryServiceNext, + InventoryNext, + UpdateRuleTypeDTO, +} from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" +import { + convertItemResponseToUpdateRequest, + getSelectsAndRelationsFromObjectArray, +} from "@medusajs/utils" + +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const updateReservationsStepId = "update-reservations-step" +export const updateReservationsStep = createStep( + updateReservationsStepId, + async (data: InventoryNext.UpdateReservationItemInput[], { container }) => { + const inventoryModuleService = container.resolve( + ModuleRegistrationName.INVENTORY + ) + + const { selects, relations } = getSelectsAndRelationsFromObjectArray(data) + const dataBeforeUpdate = await inventoryModuleService.listReservationItems( + { id: data.map((d) => d.id) }, + { relations, select: selects } + ) + + const updatedReservations = + await inventoryModuleService.updateReservationItems(data) + + return new StepResponse(updatedReservations, { + dataBeforeUpdate, + selects, + relations, + }) + }, + async (revertInput, { container }) => { + if (!revertInput) { + return + } + + const { dataBeforeUpdate = [], selects, relations } = revertInput + + const inventoryModuleService = container.resolve( + ModuleRegistrationName.INVENTORY + ) + + await inventoryModuleService.updateReservationItems( + dataBeforeUpdate.map((data) => + convertItemResponseToUpdateRequest(data, selects, relations) + ) + ) + } +) diff --git a/packages/core-flows/src/reservation/workflows/create-reservations.ts b/packages/core-flows/src/reservation/workflows/create-reservations.ts new file mode 100644 index 0000000000..fc24d2cb45 --- /dev/null +++ b/packages/core-flows/src/reservation/workflows/create-reservations.ts @@ -0,0 +1,14 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" + +import { WorkflowTypes } from "@medusajs/types" +import { createReservationsStep } from "../steps" + +export const createReservationsWorkflowId = "create-reservations-workflow" +export const createReservationsWorkflow = createWorkflow( + createReservationsWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return createReservationsStep(input.reservations) + } +) diff --git a/packages/core-flows/src/reservation/workflows/delete-reservations.ts b/packages/core-flows/src/reservation/workflows/delete-reservations.ts new file mode 100644 index 0000000000..438c1f5ef2 --- /dev/null +++ b/packages/core-flows/src/reservation/workflows/delete-reservations.ts @@ -0,0 +1,13 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" + +import { deleteReservationsStep } from "../steps" + +type WorkflowInput = { ids: string[] } + +export const deleteReservationsWorkflowId = "delete-reservations" +export const deleteReservationsWorkflow = createWorkflow( + deleteReservationsWorkflowId, + (input: WorkflowData): WorkflowData => { + return deleteReservationsStep(input.ids) + } +) diff --git a/packages/core-flows/src/reservation/workflows/index.ts b/packages/core-flows/src/reservation/workflows/index.ts new file mode 100644 index 0000000000..dca3839772 --- /dev/null +++ b/packages/core-flows/src/reservation/workflows/index.ts @@ -0,0 +1,3 @@ +export * from "./create-reservations" +export * from "./delete-reservations" +export * from "./update-reservations" diff --git a/packages/core-flows/src/reservation/workflows/update-reservations.ts b/packages/core-flows/src/reservation/workflows/update-reservations.ts new file mode 100644 index 0000000000..d066bc1068 --- /dev/null +++ b/packages/core-flows/src/reservation/workflows/update-reservations.ts @@ -0,0 +1,14 @@ +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" + +import { WorkflowTypes } from "@medusajs/types" +import { updateReservationsStep } from "../steps" + +export const updateReservationsWorkflowId = "update-reservations-workflow" +export const updateReservationsWorkflow = createWorkflow( + updateReservationsWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return updateReservationsStep(input.updates) + } +) diff --git a/packages/medusa/src/api-v2/admin/reservations/[id]/route.ts b/packages/medusa/src/api-v2/admin/reservations/[id]/route.ts new file mode 100644 index 0000000000..be7bafd4bf --- /dev/null +++ b/packages/medusa/src/api-v2/admin/reservations/[id]/route.ts @@ -0,0 +1,93 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../../types/routing" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" + +import { AdminPostReservationsReservationReq } from "../validators" +import { MedusaError } from "@medusajs/utils" +import { deleteReservationsWorkflow } from "@medusajs/core-flows" +import { updateReservationsWorkflow } from "@medusajs/core-flows" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + const remoteQuery = req.scope.resolve("remoteQuery") + + const variables = { id } + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "reservation", + variables, + fields: req.remoteQueryConfig.fields, + }) + + const [reservation] = await remoteQuery(queryObject) + + if (!reservation) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Reservation with id: ${id} was not found` + ) + } + + res.status(200).json({ reservation }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const { id } = req.params + const { errors } = await updateReservationsWorkflow(req.scope).run({ + input: { + updates: [{ ...req.validatedBody, id }], + }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "reservation", + variables: { + filters: { id: req.params.id }, + }, + fields: req.remoteQueryConfig.fields, + }) + + const [reservation] = await remoteQuery(queryObject) + + res.status(200).json({ reservation }) +} + +export const DELETE = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const id = req.params.id + + const { errors } = await deleteReservationsWorkflow(req.scope).run({ + input: { ids: [id] }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + res.status(200).json({ + id, + object: "reservation", + deleted: true, + }) +} diff --git a/packages/medusa/src/api-v2/admin/reservations/middlewares.ts b/packages/medusa/src/api-v2/admin/reservations/middlewares.ts new file mode 100644 index 0000000000..9ff4ea3af0 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/reservations/middlewares.ts @@ -0,0 +1,62 @@ +import * as QueryConfig from "./query-config" + +import { + AdminGetReservationsParams, + AdminGetReservationsReservationParams, + AdminPostReservationsReq, + AdminPostReservationsReservationReq, +} from "./validators" +import { transformBody, transformQuery } from "../../../api/middlewares" + +import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" +import { authenticate } from "../../../utils/authenticate-middleware" + +export const adminReservationRoutesMiddlewares: MiddlewareRoute[] = [ + { + method: ["ALL"], + matcher: "/admin/reservations*", + middlewares: [authenticate("admin", ["bearer", "session", "api-key"])], + }, + { + method: ["GET"], + matcher: "/admin/reservations", + middlewares: [ + transformQuery( + AdminGetReservationsParams, + QueryConfig.listTransformQueryConfig + ), + ], + }, + { + method: ["GET"], + matcher: "/admin/reservations/:id", + middlewares: [ + transformQuery( + AdminGetReservationsReservationParams, + QueryConfig.retrieveTransformQueryConfig + ), + ], + }, + { + method: ["POST"], + matcher: "/admin/reservations", + middlewares: [ + transformQuery( + AdminGetReservationsReservationParams, + QueryConfig.retrieveTransformQueryConfig + ), + transformBody(AdminPostReservationsReq), + ], + }, + { + method: ["POST"], + matcher: "/admin/reservations/:id", + middlewares: [ + transformQuery( + AdminGetReservationsReservationParams, + QueryConfig.retrieveTransformQueryConfig + ), + transformBody(AdminPostReservationsReservationReq), + ], + }, +] diff --git a/packages/medusa/src/api-v2/admin/reservations/query-config.ts b/packages/medusa/src/api-v2/admin/reservations/query-config.ts new file mode 100644 index 0000000000..9b9a9ee4b1 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/reservations/query-config.ts @@ -0,0 +1,21 @@ +export const defaultAdminReservationFields = [ + "id", + "location_id", + "inventory_item_id", + "quantity", + "line_item_id", + "description", + "metadata", + "created_at", + "updated_at", +] + +export const retrieveTransformQueryConfig = { + defaults: defaultAdminReservationFields, + isList: false, +} + +export const listTransformQueryConfig = { + ...retrieveTransformQueryConfig, + isList: true, +} diff --git a/packages/medusa/src/api-v2/admin/reservations/route.ts b/packages/medusa/src/api-v2/admin/reservations/route.ts new file mode 100644 index 0000000000..b4cd4f1a46 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/reservations/route.ts @@ -0,0 +1,70 @@ +import { + AuthenticatedMedusaRequest, + MedusaResponse, +} from "../../../types/routing" +import { + ContainerRegistrationKeys, + remoteQueryObjectFromString, +} from "@medusajs/utils" + +import { AdminPostReservationsReq } from "./validators" +import { createReservationsWorkflow } from "@medusajs/core-flows" + +export const GET = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "reservation", + variables: { + filters: req.filterableFields, + ...req.remoteQueryConfig.pagination, + }, + fields: req.remoteQueryConfig.fields, + }) + + const { rows: reservations, metadata } = await remoteQuery(queryObject) + + res.json({ + reservations, + count: metadata.count, + offset: metadata.skip, + limit: metadata.take, + }) +} + +export const POST = async ( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) => { + const input = [ + { + ...req.validatedBody, + }, + ] + + const { result, errors } = await createReservationsWorkflow(req.scope).run({ + input: { reservations: input }, + throwOnError: false, + }) + + if (Array.isArray(errors) && errors[0]) { + throw errors[0].error + } + + const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) + + const queryObject = remoteQueryObjectFromString({ + entryPoint: "reservation", + variables: { + filters: { id: result[0].id }, + }, + fields: req.remoteQueryConfig.fields, + }) + + const [reservation] = await remoteQuery(queryObject) + + res.status(200).json({ reservation }) +} diff --git a/packages/medusa/src/api-v2/admin/reservations/validators.ts b/packages/medusa/src/api-v2/admin/reservations/validators.ts new file mode 100644 index 0000000000..3657a85026 --- /dev/null +++ b/packages/medusa/src/api-v2/admin/reservations/validators.ts @@ -0,0 +1,129 @@ +import { FindParams, extendedFindParamsMixin } from "../../../types/common" +import { + IsArray, + IsBoolean, + IsNumber, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" + +import { IsType } from "../../../utils" +import { OperatorMap } from "@medusajs/types" +import { OperatorMapValidator } from "../../../types/validators/operator-map" +import { Type } from "class-transformer" + +// TODO: naming +export class AdminGetReservationsReservationParams extends FindParams {} + +/** + * Parameters used to filter and configure the pagination of the retrieved reservations. + */ +export class AdminGetReservationsParams extends extendedFindParamsMixin({ + limit: 20, + offset: 0, +}) { + /** + * Location IDs to filter reservations by. + */ + @IsOptional() + @IsType([String, [String]]) + location_id?: string | string[] + + /** + * Inventory item IDs to filter reservations by. + */ + @IsArray() + @IsString({ each: true }) + @IsOptional() + inventory_item_id?: string[] + + /** + * Line item IDs to filter reservations by. + */ + @IsArray() + @IsString({ each: true }) + @IsOptional() + line_item_id?: string[] + + /** + * "Create by" user IDs to filter reservations by. + */ + @IsArray() + @IsString({ each: true }) + @IsOptional() + created_by?: string[] + + /** + * Numerical filters to apply on the reservations' `quantity` field. + */ + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + quantity?: OperatorMap + + /** + * Date filters to apply on the reservations' `created_at` field. + */ + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + created_at?: OperatorMap + + /** + * Date filters to apply on the reservations' `updated_at` field. + */ + @IsOptional() + @ValidateNested() + @Type(() => OperatorMapValidator) + updated_at?: OperatorMap + + /** + * String filters to apply on the reservations' `description` field. + */ + @IsOptional() + @IsType([OperatorMapValidator, String]) + description?: string | OperatorMap +} + +export class AdminPostReservationsReq { + @IsString() + @IsOptional() + line_item_id?: string + + @IsString() + location_id: string + + @IsString() + inventory_item_id: string + + @IsNumber() + quantity: number + + @IsString() + @IsOptional() + description?: string + + @IsObject() + @IsOptional() + metadata?: Record +} + +export class AdminPostReservationsReservationReq { + @IsNumber() + @IsOptional() + quantity?: number + + @IsString() + @IsOptional() + location_id?: string + + @IsString() + @IsOptional() + description?: string + + @IsObject() + @IsOptional() + metadata?: Record +} diff --git a/packages/medusa/src/api-v2/middlewares.ts b/packages/medusa/src/api-v2/middlewares.ts index c5c2d1b562..d1a3385efd 100644 --- a/packages/medusa/src/api-v2/middlewares.ts +++ b/packages/medusa/src/api-v2/middlewares.ts @@ -13,10 +13,11 @@ import { adminPaymentRoutesMiddlewares } from "./admin/payments/middlewares" import { adminPriceListsRoutesMiddlewares } from "./admin/price-lists/middlewares" import { adminPricingRoutesMiddlewares } from "./admin/pricing/middlewares" import { adminProductCategoryRoutesMiddlewares } from "./admin/product-categories/middlewares" -import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares" import { adminProductRoutesMiddlewares } from "./admin/products/middlewares" +import { adminProductTypeRoutesMiddlewares } from "./admin/product-types/middlewares" import { adminPromotionRoutesMiddlewares } from "./admin/promotions/middlewares" import { adminRegionRoutesMiddlewares } from "./admin/regions/middlewares" +import { adminReservationRoutesMiddlewares } from "./admin/reservations/middlewares" import { adminSalesChannelRoutesMiddlewares } from "./admin/sales-channels/middlewares" import { adminShippingOptionRoutesMiddlewares } from "./admin/shipping-options/middlewares" import { adminShippingProfilesMiddlewares } from "./admin/shipping-profiles/middlewares" @@ -70,6 +71,7 @@ export const config: MiddlewaresConfig = { ...adminUploadRoutesMiddlewares, ...adminFulfillmentSetsRoutesMiddlewares, ...adminProductCategoryRoutesMiddlewares, + ...adminReservationRoutesMiddlewares, ...adminShippingProfilesMiddlewares, ], } diff --git a/packages/types/src/inventory/service-next.ts b/packages/types/src/inventory/service-next.ts index b07ad241ca..841be8192e 100644 --- a/packages/types/src/inventory/service-next.ts +++ b/packages/types/src/inventory/service-next.ts @@ -1,4 +1,5 @@ import { RestoreReturn, SoftDeleteReturn } from "../dal" + import { Context } from "../shared-context" import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" @@ -749,6 +750,47 @@ export interface IInventoryServiceNext extends IModuleService { context?: Context ): Promise + /** + * This method soft deletes reservations by their IDs. + * + * @param {string[]} inventoryLevelIds - The reservations' IDs. + * @param {SoftDeleteReturn} config - An object that is used to specify an entity's related entities that should be soft-deleted when the main entity is soft-deleted. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise>} An object that includes the IDs of related records that were also soft deleted. + * If there are no related records, the promise resolves to `void`. + * + * @example + * await inventoryModuleService.softDeleteReservationItems([ + * "ilev_123", + * ]) + */ + softDeleteReservationItems( + ReservationItemIds: string[], + config?: SoftDeleteReturn, + sharedContext?: Context + ): Promise | void> + + /** + * This method restores soft deleted reservations by their IDs. + * + * @param {string[]} ReservationItemIds - The reservations' IDs. + * @param {RestoreReturn} config - Configurations determining which relations to restore along with each of the reservation. You can pass to its `returnLinkableKeys` + * property any of the reservation's relation attribute names, such as `{type relation name}`. + * @param {Context} sharedContext - A context used to share resources, such as transaction manager, between the application and the module. + * @returns {Promise>} An object that includes the IDs of related records that were restored. + * If there are no related records restored, the promise resolves to `void`. + * + * @example + * await inventoryModuleService.restoreReservationItems([ + * "ilev_123", + * ]) + */ + restoreReservationItems( + ReservationItemIds: string[], + config?: RestoreReturn, + sharedContext?: Context + ): Promise | void> + /** * This method deletes inventory items by their IDs. * diff --git a/packages/types/src/workflow/index.ts b/packages/types/src/workflow/index.ts index 560860cfec..602eb649d6 100644 --- a/packages/types/src/workflow/index.ts +++ b/packages/types/src/workflow/index.ts @@ -7,3 +7,4 @@ export * as UserWorkflow from "./user" export * as RegionWorkflow from "./region" export * as InviteWorkflow from "./invite" export * as FulfillmentWorkflow from "./fulfillment" +export * as ReservationWorkflow from "./reservation" diff --git a/packages/types/src/workflow/reservation/create-reservations.ts b/packages/types/src/workflow/reservation/create-reservations.ts new file mode 100644 index 0000000000..18f4efcd31 --- /dev/null +++ b/packages/types/src/workflow/reservation/create-reservations.ts @@ -0,0 +1,8 @@ +import { InventoryNext } from "../../inventory" + +export interface CreateReservationsWorkflowInput { + reservations: InventoryNext.CreateReservationItemInput[] +} + +export type CreateReservationsWorkflowOutput = + InventoryNext.ReservationItemDTO[] diff --git a/packages/types/src/workflow/reservation/index.ts b/packages/types/src/workflow/reservation/index.ts new file mode 100644 index 0000000000..b16cf9c397 --- /dev/null +++ b/packages/types/src/workflow/reservation/index.ts @@ -0,0 +1,2 @@ +export * from "./create-reservations" +export * from "./update-reservations" diff --git a/packages/types/src/workflow/reservation/update-reservations.ts b/packages/types/src/workflow/reservation/update-reservations.ts new file mode 100644 index 0000000000..df2be4dbe3 --- /dev/null +++ b/packages/types/src/workflow/reservation/update-reservations.ts @@ -0,0 +1,8 @@ +import { InventoryNext } from "../../inventory" + +export interface UpdateReservationsWorkflowInput { + updates: InventoryNext.UpdateReservationItemInput[] +} + +export type UpdateReservationsWorkflowOutput = + InventoryNext.ReservationItemDTO[]