diff --git a/.changeset/new-cherries-knock.md b/.changeset/new-cherries-knock.md new file mode 100644 index 0000000000..fd83447fe1 --- /dev/null +++ b/.changeset/new-cherries-knock.md @@ -0,0 +1,7 @@ +--- +"@medusajs/medusa": patch +"@medusajs/medusa-js": patch +"medusa-react": patch +--- + +feat(medusa,medusa-js,medusa-react): Add inventory module endpoints diff --git a/integration-tests/plugins/__tests__/stock-location/delete-sales-channels.js b/integration-tests/plugins/__tests__/stock-location/delete-sales-channels.js index 6b315434ee..63a7474fa8 100644 --- a/integration-tests/plugins/__tests__/stock-location/delete-sales-channels.js +++ b/integration-tests/plugins/__tests__/stock-location/delete-sales-channels.js @@ -68,7 +68,7 @@ describe("Sales channels", () => { ) expect( - await salesChannelLocationService.listLocations(sc.id) + await salesChannelLocationService.listLocationIds(sc.id) ).toHaveLength(2) await api.delete(`/admin/sales-channels/${sc.id}`, { @@ -78,7 +78,7 @@ describe("Sales channels", () => { await expect(salesChannelService.retrieve(sc.id)).rejects.toThrowError() await expect( - salesChannelLocationService.listLocations(sc.id) + salesChannelLocationService.listLocationIds(sc.id) ).rejects.toThrowError() }) }) diff --git a/integration-tests/plugins/__tests__/stock-location/delete-stock-location.js b/integration-tests/plugins/__tests__/stock-location/delete-stock-location.js index 4ee1e9a164..5c1d37abf6 100644 --- a/integration-tests/plugins/__tests__/stock-location/delete-stock-location.js +++ b/integration-tests/plugins/__tests__/stock-location/delete-stock-location.js @@ -70,11 +70,11 @@ describe("Sales channels", () => { ) expect( - await salesChannelLocationService.listLocations(saleChannel.id) + await salesChannelLocationService.listLocationIds(saleChannel.id) ).toHaveLength(1) expect( - await salesChannelLocationService.listLocations(otherChannel.id) + await salesChannelLocationService.listLocationIds(otherChannel.id) ).toHaveLength(1) await api.delete(`/admin/stock-locations/${loc.id}`, { @@ -82,11 +82,11 @@ describe("Sales channels", () => { }) expect( - await salesChannelLocationService.listLocations(saleChannel.id) + await salesChannelLocationService.listLocationIds(saleChannel.id) ).toHaveLength(0) expect( - await salesChannelLocationService.listLocations(otherChannel.id) + await salesChannelLocationService.listLocationIds(otherChannel.id) ).toHaveLength(0) }) }) diff --git a/integration-tests/plugins/__tests__/stock-location/sales-channels.js b/integration-tests/plugins/__tests__/stock-location/sales-channels.js index 153664c256..16d13d3962 100644 --- a/integration-tests/plugins/__tests__/stock-location/sales-channels.js +++ b/integration-tests/plugins/__tests__/stock-location/sales-channels.js @@ -60,7 +60,7 @@ describe("Sales channels", () => { await salesChannelLocationService.associateLocation(sc.id, loc2.id) expect( - await salesChannelLocationService.listLocations(sc.id) + await salesChannelLocationService.listLocationIds(sc.id) ).toHaveLength(2) const [channels] = await salesChannelService.listAndCount( diff --git a/packages/medusa-js/src/resources/admin/index.ts b/packages/medusa-js/src/resources/admin/index.ts index 1a116cc2c5..40f8fc0020 100644 --- a/packages/medusa-js/src/resources/admin/index.ts +++ b/packages/medusa-js/src/resources/admin/index.ts @@ -33,6 +33,8 @@ import AdminUsersResource from "./users" import AdminVariantsResource from "./variants" import AdminPaymentCollectionsResource from "./payment-collections" import AdminPaymentsResource from "./payments" +import AdminInventoryItemsResource from "./inventory-item" +import AdminReservationsResource from "./reservations" import AdminProductCategoriesResource from "./product-categories" class Admin extends BaseResource { @@ -46,6 +48,7 @@ class Admin extends BaseResource { public draftOrders = new AdminDraftOrdersResource(this.client) public giftCards = new AdminGiftCardsResource(this.client) public invites = new AdminInvitesResource(this.client) + public inventoryItems = new AdminInventoryItemsResource(this.client) public notes = new AdminNotesResource(this.client) public priceLists = new AdminPriceListResource(this.client) public products = new AdminProductsResource(this.client) @@ -65,6 +68,7 @@ class Admin extends BaseResource { public store = new AdminStoresResource(this.client) public shippingOptions = new AdminShippingOptionsResource(this.client) public regions = new AdminRegionsResource(this.client) + public reservations = new AdminReservationsResource(this.client) public notifications = new AdminNotificationsResource(this.client) public taxRates = new AdminTaxRatesResource(this.client) public uploads = new AdminUploadsResource(this.client) diff --git a/packages/medusa-js/src/resources/admin/inventory-item.ts b/packages/medusa-js/src/resources/admin/inventory-item.ts index b3956de917..13fd6b0f6d 100644 --- a/packages/medusa-js/src/resources/admin/inventory-item.ts +++ b/packages/medusa-js/src/resources/admin/inventory-item.ts @@ -8,6 +8,7 @@ import { AdminGetInventoryItemsItemParams, AdminInventoryItemsListWithVariantsAndLocationLevelsRes, AdminInventoryItemsLocationLevelsRes, + AdminPostInventoryItemsItemLocationLevelsReq, } from "@medusajs/medusa" import { ResponsePromise } from "../../typings" import BaseResource from "../base" @@ -119,6 +120,29 @@ class AdminInventoryItemsResource extends BaseResource { return this.client.request("POST", path, payload, {}, customHeaders) } + /** + * Create stock for an Inventory Item at a Stock Location + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description creates stock levle for an Inventory Item + * @returns the Inventory Item + */ + createLocationLevel( + inventoryItemId: string, + payload: AdminPostInventoryItemsItemLocationLevelsReq, + query?: AdminGetInventoryItemsParams, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/inventory-items/${inventoryItemId}/location-levels` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + + return this.client.request("POST", path, payload, {}, customHeaders) + } + /** * Removes an Inventory Item from a Stock Location. This erases trace of any quantity currently at the location. * @experimental This feature is under development and may change in the future. diff --git a/packages/medusa-js/src/resources/admin/reservations.ts b/packages/medusa-js/src/resources/admin/reservations.ts new file mode 100644 index 0000000000..71153466fb --- /dev/null +++ b/packages/medusa-js/src/resources/admin/reservations.ts @@ -0,0 +1,97 @@ +import { + AdminPostReservationsReq, + AdminPostReservationsReservationReq, + AdminReservationsDeleteRes, + AdminReservationsRes, + AdminGetReservationsParams, + AdminReservationsListRes, +} from "@medusajs/medusa" +import qs from "qs" +import { ResponsePromise } from "../../typings" +import BaseResource from "../base" + +class AdminReservationsResource extends BaseResource { + /** + * Get a reservation + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description gets a reservation + * @returns The reservation with the provided id + */ + retrieve( + id: string, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/reservations/${id}` + return this.client.request("GET", path, undefined, {}, customHeaders) + } + + /** + * List reservations + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @description Lists reservations + * @returns A list of reservations matching the provided query + */ + list( + query?: AdminGetReservationsParams, + customHeaders: Record = {} + ): ResponsePromise { + let path = `/admin/reservations` + + if (query) { + const queryString = qs.stringify(query) + path += `?${queryString}` + } + + return this.client.request("GET", path, undefined, {}, customHeaders) + } + + /** + * create a reservation + * @description create a reservation + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @returns the created reservation + */ + create( + payload: AdminPostReservationsReq, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/reservations` + return this.client.request("POST", path, payload, {}, customHeaders) + } + + /** + * update a reservation + * @description update a reservation + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @returns The updated reservation + */ + update( + id: string, + payload: AdminPostReservationsReservationReq, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/reservations/${id}` + return this.client.request("POST", path, payload, {}, customHeaders) + } + + /** + * remove a reservation + * @description remove a reservation + * @experimental This feature is under development and may change in the future. + * To use this feature please install @medusajs/inventory + * @returns reservation removal confirmation + */ + delete( + id: string, + customHeaders: Record = {} + ): ResponsePromise { + const path = `/admin/reservations/${id}` + return this.client.request("DELETE", path, undefined, {}, customHeaders) + } +} + +export default AdminReservationsResource diff --git a/packages/medusa-react/mocks/data/fixtures.json b/packages/medusa-react/mocks/data/fixtures.json index d19b9c093d..0bbe720df5 100644 --- a/packages/medusa-react/mocks/data/fixtures.json +++ b/packages/medusa-react/mocks/data/fixtures.json @@ -1140,6 +1140,46 @@ "updated_at": "2021-03-17 12:09:59.156058+01", "metadata": null }, + "inventory_item": { + "id": "iitem_320ZXDBAB4WJVW6115VEM7", + "sku": "sku123", + "origin_country": "dk", + "hs_code": null, + "mid_code": null, + "material": null, + "weight": 10, + "lenght": 4, + "height": 5, + "width": 2, + "requires_shipping": true, + "created_at": "2021-03-17 11:58:56.975971+01", + "updated_at": "2021-03-17 12:09:59.156058+01", + "metadata": null, + "location_levels": [ + { + "id": "ilev_320ZXDBAB4WJVW6115VEM7", + "inventory_item_id": "iitem_320ZXDBAB4WJVW6115VEM7", + "location_id": "loc_01F0ZXDBAB9KVZWJVW6115VEM7", + "stocked_quantity": 1, + "reserved_quantity": 0, + "incoming_quantity": 10 + } + ] + }, + "stock_location": { + "id": "loc_01F0ZXDBAB9KVZWJVW6115VEM7", + "name": "Stock location 1", + "address_id": "addr_01F0ZXDBAB9KVZWJVW6115VEM7", + "created_at": "2021-03-17 11:58:56.975971+01", + "updated_at": "2021-03-17 12:09:59.156058+01" + }, + "reservation": { + "id": "res_01F0ZXDBAB9KVZWJVW6115VEM7", + "line_item_id": "li_01F0ZXDBAB9KVZWJVW6115VEM7", + "inventory_item_id": "iitem_320ZXDBAB4WJVW6115VEM7", + "location_id": "loc_01F0ZXDBAB9KVZWJVW6115VEM7", + "quantity": 1 + }, "invite": { "id": "invite_320ZXDBAB4WJVW6115VEM7", "user_email": "lebron@james.com", diff --git a/packages/medusa-react/mocks/handlers/admin.ts b/packages/medusa-react/mocks/handlers/admin.ts index cf2fd8ba4f..16b813506c 100644 --- a/packages/medusa-react/mocks/handlers/admin.ts +++ b/packages/medusa-react/mocks/handlers/admin.ts @@ -353,6 +353,61 @@ export const adminHandlers = [ } ), + rest.get("/admin/reservations/", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + reservations: fixtures.list("reservation"), + }) + ) + }), + rest.post("/admin/reservations/", (req, res, ctx) => { + const body = req.body as Record + return res( + ctx.status(200), + ctx.json({ + reservation: { + ...fixtures.get("reservation"), + ...body, + }, + }) + ) + }), + rest.get("/admin/reservations/:id", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + reservation: { ...fixtures.get("reservation"), id: req.params.id }, + }) + ) + }), + + rest.post("/admin/reservations/:id", (req, res, ctx) => { + const body = req.body as Record + + return res( + ctx.status(200), + ctx.json({ + reservation: { + ...fixtures.get("reservation"), + ...body, + id: req.params.id, + }, + }) + ) + }), + + rest.delete("/admin/reservations/:id", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + id: req.params.id, + object: "reservation", + deleted: true, + }) + ) + }), + rest.post("/admin/return-reasons/", (req, res, ctx) => { const body = req.body as Record return res( @@ -465,6 +520,61 @@ export const adminHandlers = [ ) }), + rest.get("/admin/stock-locations", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + stock_locations: fixtures.list("stock_location"), + }) + ) + }), + rest.post("/admin/stock-locations", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + stock_location: { + ...fixtures.get("stock_location"), + ...(req.body as any), + }, + }) + ) + }), + rest.get("/admin/stock-locations/:id", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + stock_location: { + ...fixtures.get("stock_location"), + id: req.params.id, + }, + }) + ) + }), + + rest.post("/admin/stock-locations/:id", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + stock_location: { + ...fixtures.get("stock_location"), + ...(req.body as any), + id: req.params.id, + }, + }) + ) + }), + + rest.delete("/admin/stock-locations/:id", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + id: req.params.id, + object: "stock_location", + deleted: true, + }) + ) + }), + rest.get("/admin/notifications/", (req, res, ctx) => { return res( ctx.status(200), @@ -487,6 +597,112 @@ export const adminHandlers = [ ) }), + // inventory + rest.get("/admin/inventory-items", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + inventory_items: fixtures.list("inventory_item"), + }) + ) + }), + + rest.get("/admin/inventory-items/:id", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + inventory_item: fixtures.get("inventory_item"), + }) + ) + }), + + rest.post("/admin/inventory-items/:id", (req, res, ctx) => { + const body = req.body as Record + return res( + ctx.status(200), + ctx.json({ + inventory_item: { + ...fixtures.get("inventory_item"), + ...body, + id: req.params.id, + }, + }) + ) + }), + + rest.delete("/admin/inventory-items/:id", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + id: req.params.id, + object: "inventory_item", + deleted: true, + }) + ) + }), + + rest.get("/admin/inventory-items/:id/location-levels", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + inventory_item: { + ...fixtures.get("inventory_item"), + id: req.params.id, + }, + }) + ) + }), + + rest.post("/admin/inventory-items/:id/location-levels", (req, res, ctx) => { + const body = req.body as Record + const { location_levels } = fixtures.get("inventory_item") + return res( + ctx.status(200), + ctx.json({ + inventory_item: { + ...fixtures.get("inventory_item"), + id: req.params.id, + location_levels: [...location_levels, { ...body, id: "2" }], + }, + }) + ) + }), + + rest.post( + "/admin/inventory-items/:id/location-levels/:location_id", + (req, res, ctx) => { + const body = req.body as Record + const inventoryItem = fixtures.get("inventory_item") + const locationlevel = { ...inventoryItem.location_levels[0], ...body } + return res( + ctx.status(200), + ctx.json({ + inventory_item: { + ...fixtures.get("inventory_item"), + id: req.params.id, + location_levels: [locationlevel], + }, + }) + ) + } + ), + + rest.delete( + "/admin/inventory-items/:id/location-levels/:location_id", + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + inventory_item: { + ...fixtures.get("inventory_item"), + id: req.params.id, + location_levels: [], + }, + }) + ) + } + ), + rest.get("/admin/invites", (req, res, ctx) => { return res( ctx.status(200), @@ -1189,6 +1405,24 @@ export const adminHandlers = [ ) }), + rest.get("/admin/variants/:id/inventory", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + variant: { + ...fixtures.get("product_variant"), + sales_channel_availability: [ + { + channel_name: "default channel", + channel_id: "1", + available_quantity: 10, + }, + ], + }, + }) + ) + }), + rest.get("/admin/users/:id", (req, res, ctx) => { return res( ctx.status(200), diff --git a/packages/medusa-react/src/contexts/medusa.tsx b/packages/medusa-react/src/contexts/medusa.tsx index ac7e058770..a7ad14c776 100644 --- a/packages/medusa-react/src/contexts/medusa.tsx +++ b/packages/medusa-react/src/contexts/medusa.tsx @@ -1,7 +1,7 @@ import Medusa from "@medusajs/medusa-js" import { QueryClientProvider, - QueryClientProviderProps + QueryClientProviderProps, } from "@tanstack/react-query" import React from "react" diff --git a/packages/medusa-react/src/hooks/admin/index.ts b/packages/medusa-react/src/hooks/admin/index.ts index 86a63c3e09..bb274d7136 100644 --- a/packages/medusa-react/src/hooks/admin/index.ts +++ b/packages/medusa-react/src/hooks/admin/index.ts @@ -8,6 +8,7 @@ export * from "./customers" export * from "./discounts" export * from "./draft-orders" export * from "./gift-cards" +export * from "./inventory-item" export * from "./invites" export * from "./notes" export * from "./notifications" @@ -21,6 +22,7 @@ export * from "./publishable-api-keys" export * from "./regions" export * from "./return-reasons" export * from "./returns" +export * from "./reservations" export * from "./sales-channels" export * from "./shipping-options" export * from "./shipping-profiles" @@ -32,3 +34,4 @@ export * from "./users" export * from "./variants" export * from "./payment-collections" export * from "./payments" +export * from "./stock-locations" diff --git a/packages/medusa-react/src/hooks/admin/inventory-item/index.ts b/packages/medusa-react/src/hooks/admin/inventory-item/index.ts new file mode 100644 index 0000000000..a494946b87 --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/inventory-item/index.ts @@ -0,0 +1,2 @@ +export * from "./queries" +export * from "./mutations" diff --git a/packages/medusa-react/src/hooks/admin/inventory-item/mutations.ts b/packages/medusa-react/src/hooks/admin/inventory-item/mutations.ts new file mode 100644 index 0000000000..e80e098115 --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/inventory-item/mutations.ts @@ -0,0 +1,151 @@ +import { + AdminInventoryItemsDeleteRes, + AdminInventoryItemsRes, + AdminPostInventoryItemsInventoryItemReq, + AdminPostInventoryItemsItemLocationLevelsLevelReq, + AdminPostInventoryItemsItemLocationLevelsReq, +} from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { + useMutation, + UseMutationOptions, + useQueryClient, +} from "@tanstack/react-query" +import { useMedusa } from "../../../contexts" +import { buildOptions } from "../../utils/buildOptions" +import { adminInventoryItemsKeys } from "./queries" + +// inventory item + +// update inventory item +export const useAdminUpdateInventoryItem = ( + inventoryItemId: string, + options?: UseMutationOptions< + Response, + Error, + AdminPostInventoryItemsInventoryItemReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + (payload: AdminPostInventoryItemsInventoryItemReq) => + client.admin.inventoryItems.update(inventoryItemId, payload), + buildOptions( + queryClient, + [adminInventoryItemsKeys.detail(inventoryItemId)], + options + ) + ) +} + +// delete inventory item +export const useAdminDeleteInventoryItem = ( + inventoryItemId: string, + options?: UseMutationOptions< + Response, + Error, + void + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + () => client.admin.inventoryItems.delete(inventoryItemId), + buildOptions( + queryClient, + [adminInventoryItemsKeys.detail(inventoryItemId)], + options + ) + ) +} + +// location level +export const useAdminUpdateLocationLevel = ( + inventoryItemId: string, + options?: UseMutationOptions< + Response, + Error, + AdminPostInventoryItemsItemLocationLevelsLevelReq & { + stockLocationId: string + } + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + ( + payload: AdminPostInventoryItemsItemLocationLevelsLevelReq & { + stockLocationId: string + } + ) => + client.admin.inventoryItems.updateLocationLevel( + inventoryItemId, + payload.stockLocationId, + { + incoming_quantity: payload.incoming_quantity, + stocked_quantity: payload.stocked_quantity, + } + ), + buildOptions( + queryClient, + [ + adminInventoryItemsKeys.detail(inventoryItemId), + adminInventoryItemsKeys.lists(), + ], + options + ) + ) +} + +export const useAdminDeleteLocationLevel = ( + inventoryItemId: string, + options?: UseMutationOptions, Error, string> +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + (stockLocationId: string) => + client.admin.inventoryItems.deleteLocationLevel( + inventoryItemId, + stockLocationId + ), + buildOptions( + queryClient, + [ + adminInventoryItemsKeys.detail(inventoryItemId), + adminInventoryItemsKeys.lists(), + ], + options + ) + ) +} + +export const useAdminCreateLocationLevel = ( + inventoryItemId: string, + options?: UseMutationOptions< + Response, + Error, + AdminPostInventoryItemsItemLocationLevelsReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + (payload: AdminPostInventoryItemsItemLocationLevelsReq) => + client.admin.inventoryItems.createLocationLevel(inventoryItemId, payload), + buildOptions( + queryClient, + [ + adminInventoryItemsKeys.detail(inventoryItemId), + adminInventoryItemsKeys.lists(), + ], + options + ) + ) +} diff --git a/packages/medusa-react/src/hooks/admin/inventory-item/queries.ts b/packages/medusa-react/src/hooks/admin/inventory-item/queries.ts new file mode 100644 index 0000000000..39172b0fda --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/inventory-item/queries.ts @@ -0,0 +1,79 @@ +import { + AdminGetStockLocationsParams, + AdminInventoryItemsListWithVariantsAndLocationLevelsRes, + AdminInventoryItemsLocationLevelsRes, + AdminInventoryItemsRes, +} from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { useQuery } from "@tanstack/react-query" +import { useMedusa } from "../../../contexts" +import { UseQueryOptionsWrapper } from "../../../types" +import { queryKeysFactory } from "../../utils" + +const ADMIN_INVENTORY_ITEMS_QUERY_KEY = `admin_inventory_items` as const + +export const adminInventoryItemsKeys = queryKeysFactory( + ADMIN_INVENTORY_ITEMS_QUERY_KEY +) + +type InventoryItemsQueryKeys = typeof adminInventoryItemsKeys + +export const useAdminInventoryItems = ( + query?: AdminGetStockLocationsParams, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + + const { data, ...rest } = useQuery( + adminInventoryItemsKeys.list(query), + () => client.admin.inventoryItems.list(query), + { ...options } + ) + + return { ...data, ...rest } as const +} + +export const useAdminInventoryItem = ( + inventoryItemId: string, + query?: AdminGetStockLocationsParams, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + + const { data, ...rest } = useQuery( + adminInventoryItemsKeys.detail(inventoryItemId), + () => client.admin.inventoryItems.retrieve(inventoryItemId, query), + { ...options } + ) + + return { ...data, ...rest } as const +} + +export const useAdminInventoryItemLocationLevels = ( + inventoryItemId: string, + query?: AdminGetStockLocationsParams, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + + const { data, ...rest } = useQuery( + adminInventoryItemsKeys.detail(inventoryItemId), + () => + client.admin.inventoryItems.listLocationLevels(inventoryItemId, query), + { ...options } + ) + + return { ...data, ...rest } as const +} diff --git a/packages/medusa-react/src/hooks/admin/reservations/index.ts b/packages/medusa-react/src/hooks/admin/reservations/index.ts new file mode 100644 index 0000000000..97c3d1d4b8 --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/reservations/index.ts @@ -0,0 +1,2 @@ +export * from "./mutations" +export * from "./queries" diff --git a/packages/medusa-react/src/hooks/admin/reservations/mutations.ts b/packages/medusa-react/src/hooks/admin/reservations/mutations.ts new file mode 100644 index 0000000000..0f391525a2 --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/reservations/mutations.ts @@ -0,0 +1,75 @@ +import { + AdminPostReservationsReq, + AdminPostReservationsReservationReq, + AdminReservationsDeleteRes, + AdminReservationsRes, +} from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js/src" +import { + useMutation, + UseMutationOptions, + useQueryClient, +} from "@tanstack/react-query" +import { useMedusa } from "../../../contexts" +import { buildOptions } from "../../utils/buildOptions" +import { adminReservationsKeys } from "./queries" + +export const useAdminCreateReservation = ( + options?: UseMutationOptions< + Response, + Error, + AdminPostReservationsReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + (payload: AdminPostReservationsReq) => + client.admin.reservations.create(payload), + buildOptions(queryClient, [adminReservationsKeys.list()], options) + ) +} + +export const useAdminUpdateReservation = ( + id: string, + options?: UseMutationOptions< + Response, + Error, + AdminPostReservationsReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + (payload: AdminPostReservationsReservationReq) => + client.admin.reservations.update(id, payload), + buildOptions( + queryClient, + [adminReservationsKeys.lists(), adminReservationsKeys.detail(id)], + options + ) + ) +} + +export const useAdminDeleteReservation = ( + id: string, + options?: UseMutationOptions< + Response, + Error, + void + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + () => client.admin.reservations.delete(id), + buildOptions( + queryClient, + [adminReservationsKeys.lists(), adminReservationsKeys.detail(id)], + options + ) + ) +} diff --git a/packages/medusa-react/src/hooks/admin/reservations/queries.ts b/packages/medusa-react/src/hooks/admin/reservations/queries.ts new file mode 100644 index 0000000000..df291da927 --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/reservations/queries.ts @@ -0,0 +1,56 @@ +import { + AdminGetReservationsParams, + AdminReservationsListRes, + AdminReservationsRes, +} from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { useQuery } from "@tanstack/react-query" +import { useMedusa } from "../../../contexts" +import { UseQueryOptionsWrapper } from "../../../types" +import { queryKeysFactory } from "../../utils" + +const ADMIN_RESERVATIONS_QUERY_KEY = `admin_stock_locations` as const + +export const adminReservationsKeys = queryKeysFactory( + ADMIN_RESERVATIONS_QUERY_KEY +) + +type ReservationsQueryKeys = typeof adminReservationsKeys + +export const useAdminReservations = ( + query?: AdminGetReservationsParams, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + + const { data, ...rest } = useQuery( + adminReservationsKeys.list(query), + () => client.admin.reservations.list(query), + { ...options } + ) + + return { ...data, ...rest } as const +} + +export const useAdminReservation = ( + id: string, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + + const { data, ...rest } = useQuery( + adminReservationsKeys.detail(id), + () => client.admin.reservations.retrieve(id), + options + ) + + return { ...data, ...rest } as const +} diff --git a/packages/medusa-react/src/hooks/admin/sales-channels/mutations.ts b/packages/medusa-react/src/hooks/admin/sales-channels/mutations.ts index 4706e18fa6..69254f85e0 100644 --- a/packages/medusa-react/src/hooks/admin/sales-channels/mutations.ts +++ b/packages/medusa-react/src/hooks/admin/sales-channels/mutations.ts @@ -16,6 +16,7 @@ import { import { useMedusa } from "../../../contexts" import { buildOptions } from "../../utils/buildOptions" import { adminProductKeys } from "../products" +import { adminStockLocationsKeys } from "../stock-locations" import { adminSalesChannelsKeys } from "./queries" /** @@ -162,3 +163,73 @@ export const useAdminAddProductsToSalesChannel = ( ) ) } + +/** + * Add a location to a sales channel + * @experimental This feature is under development and may change in the future. + * To use this feature please install the stock location in your medusa backend project. + * @description Add a location to a sales channel + * @param options + */ +export const useAdminAddLocationToSalesChannel = ( + options?: UseMutationOptions< + Response, + Error, + { + sales_channel_id: string + location_id: string + } + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + return useMutation(({ sales_channel_id, location_id }) => { + return client.admin.salesChannels.addLocation(sales_channel_id, { + location_id, + }) + }, buildOptions( + queryClient, + [ + adminSalesChannelsKeys.lists(), + adminSalesChannelsKeys.details(), + adminStockLocationsKeys.all + ], + options + ) + ) +} + +/** + * Remove a location from a sales channel + * @experimental This feature is under development and may change in the future. + * To use this feature please install the stock location in your medusa backend project. + * @description Remove a location from a sales channel + * @param options + */ +export const useAdminRemoveLocationFromSalesChannel = ( + options?: UseMutationOptions< + Response, + Error, + { + sales_channel_id: string + location_id: string + } + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + return useMutation(({ sales_channel_id, location_id }) => { + return client.admin.salesChannels.removeLocation(sales_channel_id, { + location_id, + }) + }, buildOptions( + queryClient, + [ + adminSalesChannelsKeys.lists(), + adminSalesChannelsKeys.details(), + adminStockLocationsKeys.all + ], + options + ) + ) +} diff --git a/packages/medusa-react/src/hooks/admin/stock-locations/index.ts b/packages/medusa-react/src/hooks/admin/stock-locations/index.ts new file mode 100644 index 0000000000..a494946b87 --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/stock-locations/index.ts @@ -0,0 +1,2 @@ +export * from "./queries" +export * from "./mutations" diff --git a/packages/medusa-react/src/hooks/admin/stock-locations/mutations.ts b/packages/medusa-react/src/hooks/admin/stock-locations/mutations.ts new file mode 100644 index 0000000000..ace0376537 --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/stock-locations/mutations.ts @@ -0,0 +1,74 @@ +import { + AdminPostStockLocationsReq, + AdminStockLocationsDeleteRes, + AdminStockLocationsRes, +} from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { + useMutation, + UseMutationOptions, + useQueryClient, +} from "@tanstack/react-query" +import { useMedusa } from "../../../contexts" +import { buildOptions } from "../../utils/buildOptions" +import { adminStockLocationsKeys } from "./queries" + +export const useAdminCreateStockLocation = ( + options?: UseMutationOptions< + Response, + Error, + AdminPostStockLocationsReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + (payload: AdminPostStockLocationsReq) => + client.admin.stockLocations.create(payload), + buildOptions(queryClient, [adminStockLocationsKeys.lists()], options) + ) +} + +export const useAdminUpdateStockLocation = ( + id: string, + options?: UseMutationOptions< + Response, + Error, + AdminPostStockLocationsReq + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + (payload: AdminPostStockLocationsReq) => + client.admin.stockLocations.update(id, payload), + buildOptions( + queryClient, + [adminStockLocationsKeys.lists(), adminStockLocationsKeys.detail(id)], + options + ) + ) +} + +export const useAdminDeleteStockLocation = ( + id: string, + options?: UseMutationOptions< + Response, + Error, + void + > +) => { + const { client } = useMedusa() + const queryClient = useQueryClient() + + return useMutation( + () => client.admin.stockLocations.delete(id), + buildOptions( + queryClient, + [adminStockLocationsKeys.lists(), adminStockLocationsKeys.detail(id)], + options + ) + ) +} diff --git a/packages/medusa-react/src/hooks/admin/stock-locations/queries.ts b/packages/medusa-react/src/hooks/admin/stock-locations/queries.ts new file mode 100644 index 0000000000..7b34d50603 --- /dev/null +++ b/packages/medusa-react/src/hooks/admin/stock-locations/queries.ts @@ -0,0 +1,56 @@ +import { + AdminGetStockLocationsParams, + AdminStockLocationsListRes, + AdminStockLocationsRes, +} from "@medusajs/medusa" +import { Response } from "@medusajs/medusa-js" +import { useQuery } from "@tanstack/react-query" +import { useMedusa } from "../../../contexts" +import { UseQueryOptionsWrapper } from "../../../types" +import { queryKeysFactory } from "../../utils" + +const ADMIN_STOCK_LOCATIONS_QUERY_KEY = `admin_stock_locations` as const + +export const adminStockLocationsKeys = queryKeysFactory( + ADMIN_STOCK_LOCATIONS_QUERY_KEY +) + +type StockLocationsQueryKeys = typeof adminStockLocationsKeys + +export const useAdminStockLocations = ( + query?: AdminGetStockLocationsParams, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + + const { data, ...rest } = useQuery( + adminStockLocationsKeys.list(query), + () => client.admin.stockLocations.list(query), + options + ) + + return { ...data, ...rest } as const +} + +export const useAdminStockLocation = ( + id: string, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + + const { data, ...rest } = useQuery( + adminStockLocationsKeys.detail(id), + () => client.admin.stockLocations.retrieve(id), + options + ) + + return { ...data, ...rest } as const +} diff --git a/packages/medusa-react/src/hooks/admin/variants/queries.ts b/packages/medusa-react/src/hooks/admin/variants/queries.ts index 6175081aca..e3edc5b4c8 100644 --- a/packages/medusa-react/src/hooks/admin/variants/queries.ts +++ b/packages/medusa-react/src/hooks/admin/variants/queries.ts @@ -1,4 +1,8 @@ -import { AdminGetVariantsParams, AdminVariantsListRes } from "@medusajs/medusa" +import { + AdminGetVariantsParams, + AdminGetVariantsVariantInventoryRes, + AdminVariantsListRes, +} from "@medusajs/medusa" import { Response } from "@medusajs/medusa-js" import { useQuery } from "@tanstack/react-query" import { useMedusa } from "../../../contexts" @@ -27,3 +31,20 @@ export const useAdminVariants = ( ) return { ...data, ...rest } as const } + +export const useAdminVariantsInventory = ( + id: string, + options?: UseQueryOptionsWrapper< + Response, + Error, + ReturnType + > +) => { + const { client } = useMedusa() + const { data, ...rest } = useQuery( + adminVariantKeys.detail(id), + () => client.admin.variants.getInventory(id), + options + ) + return { ...data, ...rest } as const +} diff --git a/packages/medusa-react/test/hooks/admin/inventory-items/mutations.test.ts b/packages/medusa-react/test/hooks/admin/inventory-items/mutations.test.ts new file mode 100644 index 0000000000..f157104a83 --- /dev/null +++ b/packages/medusa-react/test/hooks/admin/inventory-items/mutations.test.ts @@ -0,0 +1,144 @@ +import { + useAdminUpdateInventoryItem, + useAdminDeleteInventoryItem, + useAdminUpdateLocationLevel, + useAdminDeleteLocationLevel, + useAdminCreateLocationLevel, +} from "../../../../src/" +import { renderHook } from "@testing-library/react-hooks" +import { createWrapper } from "../../../utils" + +describe("useAdminUpdateInventoryItem hook", () => { + test("updates an inventory item", async () => { + const payload = { + sku: "test-sku", + } + + const { result, waitFor } = renderHook( + () => useAdminUpdateInventoryItem("inventory-item-id"), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate(payload) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.inventory_item).toEqual( + expect.objectContaining({ + id: "inventory-item-id", + sku: "test-sku", + }) + ) + }) +}) + +describe("useAdminDeleteInventoryItem hook", () => { + test("Deletes an inventory item", async () => { + const { result, waitFor } = renderHook( + () => useAdminDeleteInventoryItem("inventory-item-id"), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate() + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data).toEqual( + expect.objectContaining({ + id: "inventory-item-id", + deleted: true, + }) + ) + }) +}) + +describe("useAdminUpdateLocationLevel hook", () => { + test("Updates a location level", async () => { + const payload = { + incoming_quantity: 10, + } + + const { result, waitFor } = renderHook( + () => useAdminUpdateLocationLevel("inventory-item-id"), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate({ ...payload, stockLocationId: "location_id"}) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.inventory_item).toEqual( + expect.objectContaining({ + id: "inventory-item-id", + location_levels: [ + expect.objectContaining({ + incoming_quantity: 10, + }), + ], + }) + ) + }) +}) + +describe("useAdminDeleteLocationLevel hook", () => { + test("removes a location level", async () => { + const { result, waitFor } = renderHook( + () => useAdminDeleteLocationLevel("inventory-item-id"), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate("location_id") + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.inventory_item).toEqual( + expect.objectContaining({ + id: "inventory-item-id", + location_levels: [], + }) + ) + }) +}) + +describe("useAdminCreateLocationLevel hook", () => { + test("creates a location level", async () => { + const payload = { + location_id: "loc_1", + incoming_quantity: 10, + stocked_quantity: 10, + } + + const { result, waitFor } = renderHook( + () => useAdminCreateLocationLevel("inventory-item-id"), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate(payload) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.inventory_item).toEqual( + expect.objectContaining({ + id: "inventory-item-id", + location_levels: expect.arrayContaining([ + expect.objectContaining({ ...payload }), + ]), + }) + ) + }) +}) diff --git a/packages/medusa-react/test/hooks/admin/inventory-items/queries.test.ts b/packages/medusa-react/test/hooks/admin/inventory-items/queries.test.ts new file mode 100644 index 0000000000..50cdbdf99f --- /dev/null +++ b/packages/medusa-react/test/hooks/admin/inventory-items/queries.test.ts @@ -0,0 +1,75 @@ +import { + useAdminInventoryItem, + useAdminInventoryItemLocationLevels, + useAdminInventoryItems, + useAdminPriceList, + useAdminPriceLists, +} from "../../../../src" +import { renderHook } from "@testing-library/react-hooks" +import { fixtures } from "../../../../mocks/data" +import { createWrapper } from "../../../utils" + +describe("useAdminInventoryItems hook", () => { + test("returns a list of inventory items", async () => { + const inventoryItems = fixtures.list("inventory_item") + const { result, waitFor } = renderHook(() => useAdminInventoryItems(), { + wrapper: createWrapper(), + }) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.inventory_items).toEqual(inventoryItems) + }) +}) + +describe("useAdminInventoryItem hook", () => { + test("returns a single inventory item", async () => { + const inventoryItem = fixtures.get("inventory_item") + const { result, waitFor } = renderHook( + () => useAdminInventoryItem(inventoryItem.id), + { + wrapper: createWrapper(), + } + ) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.inventory_item).toEqual(inventoryItem) + }) +}) + +describe("useAdminInventoryItem hook", () => { + test("returns a location levels for an inventory item", async () => { + const inventoryItem = fixtures.get("inventory_item") + const { result, waitFor } = renderHook( + () => useAdminInventoryItemLocationLevels(inventoryItem.id), + { + wrapper: createWrapper(), + } + ) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.inventory_item).toEqual(inventoryItem) + }) +}) + +describe("useAdminPriceList hook", () => { + test("returns a price list", async () => { + const priceList = fixtures.get("price_list") + const { result, waitFor } = renderHook( + () => useAdminPriceList(priceList.id), + { + wrapper: createWrapper(), + } + ) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.price_list).toEqual(priceList) + }) +}) diff --git a/packages/medusa-react/test/hooks/admin/reservations/mutations.test.ts b/packages/medusa-react/test/hooks/admin/reservations/mutations.test.ts new file mode 100644 index 0000000000..2a6c975f3a --- /dev/null +++ b/packages/medusa-react/test/hooks/admin/reservations/mutations.test.ts @@ -0,0 +1,88 @@ +import { + useAdminCreateShippingProfile, + useAdminUpdateShippingProfile, + useAdminDeleteShippingProfile, + useAdminCreateReservation, + useAdminUpdateReservation, + useAdminDeleteReservation, +} from "../../../../src/" +import { renderHook } from "@testing-library/react-hooks" +import { fixtures } from "../../../../mocks/data" +import { createWrapper } from "../../../utils" + +describe("useAdminCreateShippingProfile hook", () => { + test("creates a shipping profile and returns it", async () => { + const reservationPayload = { + location_id: "loc_1", + inventory_item_id: "inv_1", + quantity: 2, + } + + const { result, waitFor } = renderHook(() => useAdminCreateReservation(), { + wrapper: createWrapper(), + }) + + result.current.mutate(reservationPayload) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.reservation).toEqual( + expect.objectContaining({ + ...fixtures.get("reservation"), + ...reservationPayload, + }) + ) + }) +}) + +describe("useAdminUpdateShippingProfile hook", () => { + test("updates a shipping profile and returns it", async () => { + const reservationPayload = { + quantity: 3, + } + + const { result, waitFor } = renderHook( + () => useAdminUpdateReservation(fixtures.get("reservation").id), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate(reservationPayload) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.reservation).toEqual( + expect.objectContaining({ + ...fixtures.get("reservation"), + quantity: 3, + }) + ) + }) +}) + +describe("useAdminDeleteShippingProfile hook", () => { + test("deletes a shipping profile", async () => { + const { result, waitFor } = renderHook( + () => useAdminDeleteReservation(fixtures.get("reservation").id), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate() + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data).toEqual( + expect.objectContaining({ + id: fixtures.get("reservation").id, + object: "reservation", + deleted: true, + }) + ) + }) +}) diff --git a/packages/medusa-react/test/hooks/admin/reservations/queries.test.ts b/packages/medusa-react/test/hooks/admin/reservations/queries.test.ts new file mode 100644 index 0000000000..9bbf019475 --- /dev/null +++ b/packages/medusa-react/test/hooks/admin/reservations/queries.test.ts @@ -0,0 +1,35 @@ +import { useAdminReservation, useAdminReservations } from "../../../../src" +import { renderHook } from "@testing-library/react-hooks" +import { fixtures } from "../../../../mocks/data" +import { createWrapper } from "../../../utils" + +describe("useAdminShippingProfiles hook", () => { + test("returns a list of shipping profiles", async () => { + const reservations = fixtures.list("reservation") + const { result, waitFor } = renderHook(() => useAdminReservations(), { + wrapper: createWrapper(), + }) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.reservations).toEqual(reservations) + }) +}) + +describe("useAdminShippingProfile hook", () => { + test("returns a shipping profile", async () => { + const reservation = fixtures.get("reservation") + const { result, waitFor } = renderHook( + () => useAdminReservation(reservation.id), + { + wrapper: createWrapper(), + } + ) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.reservation).toEqual(reservation) + }) +}) diff --git a/packages/medusa-react/test/hooks/admin/sales-channels/mutations.test.ts b/packages/medusa-react/test/hooks/admin/sales-channels/mutations.test.ts index c4cac5e005..35c14718ac 100644 --- a/packages/medusa-react/test/hooks/admin/sales-channels/mutations.test.ts +++ b/packages/medusa-react/test/hooks/admin/sales-channels/mutations.test.ts @@ -97,15 +97,15 @@ describe("useAdminDeleteProductsFromSalesChannel hook", () => { { wrapper: createWrapper() } ) - result.current.mutate({ product_ids: [ - { id: productId } - ]}) + result.current.mutate({ product_ids: [{ id: productId }] }) await waitFor(() => result.current.isSuccess) - expect(result.current.data).toEqual(expect.objectContaining({ - sales_channel: fixtures.get("sales_channel"), - })) + expect(result.current.data).toEqual( + expect.objectContaining({ + sales_channel: fixtures.get("sales_channel"), + }) + ) }) }) @@ -119,14 +119,14 @@ describe("useAdminAddProductsToSalesChannel hook", () => { { wrapper: createWrapper() } ) - result.current.mutate({ product_ids: [ - { id: productId } - ]}) + result.current.mutate({ product_ids: [{ id: productId }] }) await waitFor(() => result.current.isSuccess) - expect(result.current.data).toEqual(expect.objectContaining({ - sales_channel: fixtures.get("sales_channel"), - })) + expect(result.current.data).toEqual( + expect.objectContaining({ + sales_channel: fixtures.get("sales_channel"), + }) + ) }) }) diff --git a/packages/medusa-react/test/hooks/admin/stock-location/mutations.test.ts b/packages/medusa-react/test/hooks/admin/stock-location/mutations.test.ts new file mode 100644 index 0000000000..b130eacb31 --- /dev/null +++ b/packages/medusa-react/test/hooks/admin/stock-location/mutations.test.ts @@ -0,0 +1,87 @@ +import { renderHook } from "@testing-library/react-hooks" +import { fixtures } from "../../../../mocks/data" +import { + useAdminCreateStockLocation, + useAdminDeleteStockLocation, + useAdminUpdateStockLocation, +} from "../../../../src" +import { createWrapper } from "../../../utils" + +describe("useAdminUpdateStockLocation hook", () => { + test("updates a stock location", async () => { + const payload = { + name: "updated name", + } + + const { result, waitFor } = renderHook( + () => useAdminUpdateStockLocation("stock-location-id"), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate(payload) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.stock_location).toEqual( + expect.objectContaining({ + id: "stock-location-id", + name: "updated name", + }) + ) + }) +}) + +describe("useAdminCreateStockLocation hook", () => { + test("creates a stock location", async () => { + const locationFixture = fixtures.get("stock_location") + const payload = { + name: "updated name", + } + + const { result, waitFor } = renderHook( + () => useAdminCreateStockLocation(), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate(payload) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data.stock_location).toEqual( + expect.objectContaining({ + ...locationFixture, + ...payload, + }) + ) + }) +}) + +describe("useAdminDeleteStockLocation hook", () => { + test("deletes a stock location", async () => { + const { result, waitFor } = renderHook( + () => useAdminDeleteStockLocation("stock-location-id"), + { + wrapper: createWrapper(), + } + ) + + result.current.mutate() + + await waitFor(() => result.current.isSuccess) + + expect(result.current.data.response.status).toEqual(200) + expect(result.current.data).toEqual( + expect.objectContaining({ + id: "stock-location-id", + object: "stock_location", + deleted: true, + }) + ) + }) +}) diff --git a/packages/medusa-react/test/hooks/admin/stock-location/queries.test.ts b/packages/medusa-react/test/hooks/admin/stock-location/queries.test.ts new file mode 100644 index 0000000000..84a75e9342 --- /dev/null +++ b/packages/medusa-react/test/hooks/admin/stock-location/queries.test.ts @@ -0,0 +1,40 @@ +import { renderHook } from "@testing-library/react-hooks" +import { fixtures } from "../../../../mocks/data" +import { useAdminStockLocation, useAdminStockLocations } from "../../../../src" +import { createWrapper } from "../../../utils" + +describe("useAdminUpdateStockLocation hook", () => { + test("gets a stock location", async () => { + const stockLocation = fixtures.get("stock_location") + const { result, waitFor } = renderHook( + () => useAdminStockLocation("stock-location-id"), + { + wrapper: createWrapper(), + } + ) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.stock_location).toEqual( + expect.objectContaining({ + ...stockLocation, + id: "stock-location-id", + }) + ) + }) +}) + +describe("useAdminUpdateStockLocations hook", () => { + test("lists stock locations", async () => { + const stockLocation = fixtures.list("stock_location") + const { result, waitFor } = renderHook(() => useAdminStockLocations(), { + wrapper: createWrapper(), + }) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.stock_locations).toEqual(stockLocation) + }) +}) diff --git a/packages/medusa-react/test/hooks/admin/variants/queries.test.ts b/packages/medusa-react/test/hooks/admin/variants/queries.test.ts index 6935ce13ea..0de891e2dd 100644 --- a/packages/medusa-react/test/hooks/admin/variants/queries.test.ts +++ b/packages/medusa-react/test/hooks/admin/variants/queries.test.ts @@ -1,4 +1,4 @@ -import { useAdminVariants } from "../../../../src" +import { useAdminVariants, useAdminVariantsInventory } from "../../../../src" import { renderHook } from "@testing-library/react-hooks" import { fixtures } from "../../../../mocks/data" import { createWrapper } from "../../../utils" @@ -16,3 +16,29 @@ describe("useAdminVariants hook", () => { expect(result.current.variants).toEqual(variants) }) }) + +describe("useAdminVariants hook", () => { + test("returns a variant with saleschannel locations", async () => { + const variant = fixtures.get("product_variant") + const { result, waitFor } = renderHook( + () => useAdminVariantsInventory(variant.id), + { + wrapper: createWrapper(), + } + ) + + await waitFor(() => result.current.isSuccess) + + expect(result.current.response.status).toEqual(200) + expect(result.current.variant).toEqual({ + ...variant, + sales_channel_availability: [ + { + channel_name: "default channel", + channel_id: "1", + available_quantity: 10, + }, + ], + }) + }) +}) diff --git a/packages/medusa/src/api/index.js b/packages/medusa/src/api/index.js index 54c9efc06c..35268d3ff2 100644 --- a/packages/medusa/src/api/index.js +++ b/packages/medusa/src/api/index.js @@ -43,6 +43,7 @@ export * from "./routes/admin/publishable-api-keys" export * from "./routes/admin/regions" export * from "./routes/admin/return-reasons" export * from "./routes/admin/returns" +export * from "./routes/admin/reservations" export * from "./routes/admin/sales-channels" export * from "./routes/admin/shipping-options" export * from "./routes/admin/shipping-profiles" diff --git a/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts b/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts index eace5a6339..c5e3d50ba2 100644 --- a/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts +++ b/packages/medusa/src/api/routes/admin/reservations/create-reservation.ts @@ -82,7 +82,6 @@ export default async (req, res) => { * @schema AdminPostReservationsReq * type: object * required: - * - line_item_id * - location_id * - inventory_item_id * - quantity diff --git a/packages/medusa/src/api/routes/admin/reservations/index.ts b/packages/medusa/src/api/routes/admin/reservations/index.ts index e79e95148a..77ff14803e 100644 --- a/packages/medusa/src/api/routes/admin/reservations/index.ts +++ b/packages/medusa/src/api/routes/admin/reservations/index.ts @@ -109,3 +109,4 @@ export * from "./create-reservation" export * from "./delete-reservation" export * from "./get-reservation" export * from "./update-reservation" +export * from "./list-reservations" diff --git a/packages/medusa/src/api/routes/admin/sales-channels/index.ts b/packages/medusa/src/api/routes/admin/sales-channels/index.ts index 0e7e94a548..29741c1e50 100644 --- a/packages/medusa/src/api/routes/admin/sales-channels/index.ts +++ b/packages/medusa/src/api/routes/admin/sales-channels/index.ts @@ -15,6 +15,7 @@ import { AdminGetSalesChannelsParams } from "./list-sales-channels" import { AdminPostSalesChannelsSalesChannelReq } from "./update-sales-channel" import { AdminPostSalesChannelsChannelStockLocationsReq } from "./associate-stock-location" import { AdminDeleteSalesChannelsChannelStockLocationsReq } from "./remove-stock-location" +import { checkRegisteredModules } from "../../../middlewares/check-registered-modules" const route = Router() @@ -47,11 +48,19 @@ export default (app) => { ) salesChannelRouter.post( "/stock-locations", + checkRegisteredModules({ + stockLocationService: + "Stock Locations are not enabled. Please add a Stock Location module to enable this functionality.", + }), transformBody(AdminPostSalesChannelsChannelStockLocationsReq), middlewares.wrap(require("./associate-stock-location").default) ) salesChannelRouter.delete( "/stock-locations", + checkRegisteredModules({ + stockLocationService: + "Stock Locations are not enabled. Please add a Stock Location module to enable this functionality.", + }), transformBody(AdminDeleteSalesChannelsChannelStockLocationsReq), middlewares.wrap(require("./remove-stock-location").default) ) diff --git a/packages/medusa/src/api/routes/admin/stock-locations/get-stock-location.ts b/packages/medusa/src/api/routes/admin/stock-locations/get-stock-location.ts index 942fd20b01..3731d752bd 100644 --- a/packages/medusa/src/api/routes/admin/stock-locations/get-stock-location.ts +++ b/packages/medusa/src/api/routes/admin/stock-locations/get-stock-location.ts @@ -1,6 +1,11 @@ import { IStockLocationService } from "../../../../interfaces" import { Request, Response } from "express" import { FindParams } from "../../../../types/common" +import { joinSalesChannels } from "./utils/join-sales-channels" +import { + SalesChannelLocationService, + SalesChannelService, +} from "../../../../services" /** * @oas [get] /stock-locations/{id} @@ -50,7 +55,34 @@ export default async (req: Request, res: Response) => { const locationService: IStockLocationService = req.scope.resolve( "stockLocationService" ) - const stockLocation = await locationService.retrieve(id, req.retrieveConfig) + const channelLocationService: SalesChannelLocationService = req.scope.resolve( + "salesChannelLocationService" + ) + const salesChannelService: SalesChannelService = req.scope.resolve( + "salesChannelService" + ) + + const { retrieveConfig } = req + + const includeSalesChannels = + !!retrieveConfig.relations?.includes("sales_channels") + + if (includeSalesChannels) { + retrieveConfig.relations = retrieveConfig.relations?.filter( + (r) => r !== "sales_channels" + ) + } + + let stockLocation = await locationService.retrieve(id, retrieveConfig) + + if (includeSalesChannels) { + const [location] = await joinSalesChannels( + [stockLocation], + channelLocationService, + salesChannelService + ) + stockLocation = location + } res.status(200).json({ stock_location: stockLocation }) } diff --git a/packages/medusa/src/api/routes/admin/stock-locations/index.ts b/packages/medusa/src/api/routes/admin/stock-locations/index.ts index 0347f9cc54..fb4724d648 100644 --- a/packages/medusa/src/api/routes/admin/stock-locations/index.ts +++ b/packages/medusa/src/api/routes/admin/stock-locations/index.ts @@ -1,7 +1,10 @@ import { Router } from "express" import "reflect-metadata" import { DeleteResponse, PaginatedResponse } from "../../../../types/common" -import { StockLocationDTO } from "../../../../types/stock-location" +import { + StockLocationDTO, + StockLocationExpandedDTO, +} from "../../../../types/stock-location" import middlewares, { transformBody, transformQuery, @@ -113,10 +116,10 @@ export type AdminStockLocationsDeleteRes = DeleteResponse * type: object * properties: * stock_location: - * $ref: "#/components/schemas/StockLocationDTO" + * $ref: "#/components/schemas/StockLocationExpandedDTO" */ export type AdminStockLocationsRes = { - stock_location: StockLocationDTO + stock_location: StockLocationExpandedDTO } /** @@ -126,7 +129,7 @@ export type AdminStockLocationsRes = { * stock_locations: * type: array * items: - * $ref: "#/components/schemas/StockLocationDTO" + * $ref: "#/components/schemas/StockLocationExpandedDTO" * count: * type: integer * description: The total number of items available @@ -138,7 +141,7 @@ export type AdminStockLocationsRes = { * description: The number of items per page */ export type AdminStockLocationsListRes = PaginatedResponse & { - stock_locations: StockLocationDTO[] + stock_locations: StockLocationExpandedDTO[] } export * from "./list-stock-locations" diff --git a/packages/medusa/src/api/routes/admin/stock-locations/list-stock-locations.ts b/packages/medusa/src/api/routes/admin/stock-locations/list-stock-locations.ts index fca846fe31..2963b22b11 100644 --- a/packages/medusa/src/api/routes/admin/stock-locations/list-stock-locations.ts +++ b/packages/medusa/src/api/routes/admin/stock-locations/list-stock-locations.ts @@ -4,6 +4,11 @@ import { IsType } from "../../../../utils/validators/is-type" import { IStockLocationService } from "../../../../interfaces" import { extendedFindParamsMixin } from "../../../../types/common" import { Request, Response } from "express" +import { + SalesChannelLocationService, + SalesChannelService, +} from "../../../../services" +import { joinSalesChannels } from "./utils/join-sales-channels" /** * @oas [get] /stock-locations @@ -133,15 +138,38 @@ export default async (req: Request, res: Response) => { const stockLocationService: IStockLocationService = req.scope.resolve( "stockLocationService" ) + const channelLocationService: SalesChannelLocationService = req.scope.resolve( + "salesChannelLocationService" + ) + const salesChannelService: SalesChannelService = req.scope.resolve( + "salesChannelService" + ) const { filterableFields, listConfig } = req const { skip, take } = listConfig - const [locations, count] = await stockLocationService.listAndCount( + const includeSalesChannels = + !!listConfig.relations?.includes("sales_channels") + + if (includeSalesChannels) { + listConfig.relations = listConfig.relations?.filter( + (r) => r !== "sales_channels" + ) + } + + let [locations, count] = await stockLocationService.listAndCount( filterableFields, listConfig ) + if (includeSalesChannels) { + locations = await joinSalesChannels( + locations, + channelLocationService, + salesChannelService + ) + } + res.status(200).json({ stock_locations: locations, count, diff --git a/packages/medusa/src/api/routes/admin/stock-locations/utils/join-sales-channels.ts b/packages/medusa/src/api/routes/admin/stock-locations/utils/join-sales-channels.ts new file mode 100644 index 0000000000..0c59072469 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/stock-locations/utils/join-sales-channels.ts @@ -0,0 +1,31 @@ +import { + SalesChannelLocationService, + SalesChannelService, +} from "../../../../../services" +import { + StockLocationDTO, + StockLocationExpandedDTO, +} from "../../../../../types/stock-location" + +const joinSalesChannels = async ( + locations: StockLocationDTO[], + channelLocationService: SalesChannelLocationService, + salesChannelService: SalesChannelService +): Promise => { + return await Promise.all( + locations.map(async (location: StockLocationExpandedDTO) => { + const salesChannelIds = await channelLocationService.listSalesChannelIds( + location.id + ) + const [salesChannels] = await salesChannelService.listAndCount({ + id: salesChannelIds, + }) + + location.sales_channels = salesChannels + + return location + }) + ) +} + +export { joinSalesChannels } diff --git a/packages/medusa/src/api/routes/admin/store/get-store.ts b/packages/medusa/src/api/routes/admin/store/get-store.ts index cd031167cc..f522bfd419 100644 --- a/packages/medusa/src/api/routes/admin/store/get-store.ts +++ b/packages/medusa/src/api/routes/admin/store/get-store.ts @@ -1,11 +1,9 @@ -import { FulfillmentProvider, PaymentProvider, Store } from "../../../../models" import { FulfillmentProviderService, PaymentProviderService, StoreService, } from "../../../../services" -import { FeatureFlagsResponse } from "../../../../types/feature-flags" -import { ModulesResponse } from "../../../../types/modules" +import { ExtendedStoreDTO } from "../../../../types/store" import { FlagRouter } from "../../../../utils/flag-router" import { ModulesHelper } from "../../../../utils/module-helper" @@ -77,12 +75,7 @@ export default async (req, res) => { const data = (await storeService.retrieve({ relations, - })) as Store & { - payment_providers: PaymentProvider[] - fulfillment_providers: FulfillmentProvider[] - feature_flags: FeatureFlagsResponse - modules: ModulesResponse - } + })) as ExtendedStoreDTO data.feature_flags = featureFlagRouter.listFlags() data.modules = modulesHelper.modules diff --git a/packages/medusa/src/api/routes/admin/store/index.ts b/packages/medusa/src/api/routes/admin/store/index.ts index dfd6a7da90..ced7f414d0 100644 --- a/packages/medusa/src/api/routes/admin/store/index.ts +++ b/packages/medusa/src/api/routes/admin/store/index.ts @@ -1,6 +1,7 @@ import { Router } from "express" -import { PaymentProvider, Store, TaxProvider } from "./../../../../" +import { PaymentProvider, TaxProvider } from "./../../../../" import middlewares from "../../../middlewares" +import { ExtendedStoreDTO } from "../../../../types/store" const route = Router() @@ -34,10 +35,10 @@ export default (app) => { * type: object * properties: * store: - * $ref: "#/components/schemas/Store" + * $ref: "#/components/schemas/ExtendedStoreDTO" */ export type AdminStoresRes = { - store: Store + store: ExtendedStoreDTO } /** diff --git a/packages/medusa/src/api/routes/admin/variants/get-inventory.ts b/packages/medusa/src/api/routes/admin/variants/get-inventory.ts index 440be75ca1..3de41126ed 100644 --- a/packages/medusa/src/api/routes/admin/variants/get-inventory.ts +++ b/packages/medusa/src/api/routes/admin/variants/get-inventory.ts @@ -72,7 +72,9 @@ export default async (req, res) => { const inventoryService: IInventoryService = req.scope.resolve("inventoryService") - + const channelLocationService: SalesChannelLocationService = req.scope.resolve( + "salesChannelLocationService" + ) const channelService: SalesChannelService = req.scope.resolve( "salesChannelService" ) @@ -91,11 +93,17 @@ export default async (req, res) => { sales_channel_availability: [], } - const [channels] = await channelService.listAndCount( - {}, - { - relations: ["locations"], - } + const [rawChannels] = await channelService.listAndCount({}) + const channels: SalesChannelDTO[] = await Promise.all( + rawChannels.map(async (channel) => { + const locationIds = await channelLocationService.listLocationIds( + channel.id + ) + return { + ...channel, + locations: locationIds, + } + }) ) const inventory = @@ -116,7 +124,7 @@ export default async (req, res) => { const quantity = await inventoryService.retrieveAvailableQuantity( inventory[0].id, - channel.locations.map((loc) => loc.id) + channel.locations ) return { @@ -133,6 +141,10 @@ export default async (req, res) => { }) } +type SalesChannelDTO = Omit & { + locations: string[] +} + type ResponseInventoryItem = Partial & { location_levels?: InventoryLevelDTO[] } diff --git a/packages/medusa/src/services/product-variant-inventory.ts b/packages/medusa/src/services/product-variant-inventory.ts index 0bebf60fda..618f441d66 100644 --- a/packages/medusa/src/services/product-variant-inventory.ts +++ b/packages/medusa/src/services/product-variant-inventory.ts @@ -105,9 +105,9 @@ class ProductVariantInventoryService extends TransactionBaseService { return true } - let locations: string[] = [] + let locationIds: string[] = [] if (context.salesChannelId) { - locations = await this.salesChannelLocationService_.listLocations( + locationIds = await this.salesChannelLocationService_.listLocationIds( context.salesChannelId ) } else { @@ -115,7 +115,7 @@ class ProductVariantInventoryService extends TransactionBaseService { {}, { select: ["id"] } ) - locations = stockLocations.map((l) => l.id) + locationIds = stockLocations.map((l) => l.id) } const hasInventory = await Promise.all( @@ -125,7 +125,7 @@ class ProductVariantInventoryService extends TransactionBaseService { .withTransaction(manager) .confirmInventory( inventoryPart.inventory_item_id, - locations, + locationIds, itemQuantity ) }) @@ -382,7 +382,7 @@ class ProductVariantInventoryService extends TransactionBaseService { if (!isDefined(locationId) && context.salesChannelId) { const locations = await this.salesChannelLocationService_ .withTransaction(manager) - .listLocations(context.salesChannelId) + .listLocationIds(context.salesChannelId) if (!locations.length) { throw new MedusaError( diff --git a/packages/medusa/src/services/sales-channel-inventory.ts b/packages/medusa/src/services/sales-channel-inventory.ts index 12be70583d..7a64519fca 100644 --- a/packages/medusa/src/services/sales-channel-inventory.ts +++ b/packages/medusa/src/services/sales-channel-inventory.ts @@ -40,13 +40,13 @@ class SalesChannelInventoryService { salesChannelId: string, inventoryItemId: string ): Promise { - const locations = await this.salesChannelLocationService_.listLocations( + const locationIds = await this.salesChannelLocationService_.listLocationIds( salesChannelId ) return await this.inventoryService_.retrieveAvailableQuantity( inventoryItemId, - locations + locationIds ) } } diff --git a/packages/medusa/src/services/sales-channel-location.ts b/packages/medusa/src/services/sales-channel-location.ts index d0dd7a45b9..cfc6bfc3bc 100644 --- a/packages/medusa/src/services/sales-channel-location.ts +++ b/packages/medusa/src/services/sales-channel-location.ts @@ -101,7 +101,7 @@ class SalesChannelLocationService extends TransactionBaseService { * @param salesChannelId - The ID of the sales channel. * @returns A promise that resolves with an array of location IDs. */ - async listLocations(salesChannelId: string): Promise { + async listLocationIds(salesChannelId: string): Promise { const manager = this.transactionManager_ || this.manager_ const salesChannel = await this.salesChannelService_ .withTransaction(manager) @@ -109,10 +109,28 @@ class SalesChannelLocationService extends TransactionBaseService { const locations = await manager.find(SalesChannelLocation, { where: { sales_channel_id: salesChannel.id }, + select: ["location_id"], }) return locations.map((l) => l.location_id) } + + /** + * Lists the sales channels associated with a stock location. + * @param {string} salesChannelId - The ID of the stock location. + * @returns {Promise} A promise that resolves with an array of sales channel IDs. + */ + async listSalesChannelIds(locationId: string): Promise { + const manager = this.transactionManager_ || this.manager_ + const location = await this.stockLocationService.retrieve(locationId) + + const salesChannelLocations = await manager.find(SalesChannelLocation, { + where: { location_id: location.id }, + select: ["sales_channel_id"], + }) + + return salesChannelLocations.map((l) => l.sales_channel_id) + } } export default SalesChannelLocationService diff --git a/packages/medusa/src/types/feature-flags.ts b/packages/medusa/src/types/feature-flags.ts index b40a8fb09b..074acba22a 100644 --- a/packages/medusa/src/types/feature-flags.ts +++ b/packages/medusa/src/types/feature-flags.ts @@ -3,6 +3,22 @@ export interface IFlagRouter { listFlags: () => FeatureFlagsResponse } +/** + * @schema FeatureFlagsResponse + * type: array + * items: + * type: object + * required: + * - key + * - value + * properties: + * key: + * description: The key of the feature flag. + * type: string + * value: + * description: The value of the feature flag. + * type: boolean + */ export type FeatureFlagsResponse = { key: string value: boolean diff --git a/packages/medusa/src/types/modules.ts b/packages/medusa/src/types/modules.ts index c11f53728f..262b10cd01 100644 --- a/packages/medusa/src/types/modules.ts +++ b/packages/medusa/src/types/modules.ts @@ -1,3 +1,19 @@ +/** + * @schema ModulesResponse + * type: array + * items: + * type: object + * required: + * - module + * - resolution + * properties: + * module: + * description: The key of the module. + * type: string + * resolution: + * description: The resolution path of the module or false if module is not installed. + * type: string + */ export type ModulesResponse = { module: string resolution: string | false diff --git a/packages/medusa/src/types/stock-location.ts b/packages/medusa/src/types/stock-location.ts index 5d81692f45..9d17073630 100644 --- a/packages/medusa/src/types/stock-location.ts +++ b/packages/medusa/src/types/stock-location.ts @@ -1,3 +1,4 @@ +import { SalesChannel } from "../models" import { StringComparisonOperator } from "./common" /** @@ -137,6 +138,19 @@ export type StockLocationDTO = { deleted_at: string | Date | null } +/** + * @schema StockLocationExpandedDTO + * allOf: + * - $ref: "#/components/schemas/StockLocationDTO" + * - type: object + * properties: + * sales_channels: + * $ref: "#/components/schemas/SalesChannel" + */ +export type StockLocationExpandedDTO = StockLocationDTO & { + sales_channels?: SalesChannel[] +} + export type FilterableStockLocationProps = { id?: string | string[] name?: string | string[] | StringComparisonOperator diff --git a/packages/medusa/src/types/store.ts b/packages/medusa/src/types/store.ts index 87ec3f316f..eae26cb85e 100644 --- a/packages/medusa/src/types/store.ts +++ b/packages/medusa/src/types/store.ts @@ -1,3 +1,7 @@ +import { Store, PaymentProvider, FulfillmentProvider } from "../models" +import { FeatureFlagsResponse } from "./feature-flags" +import { ModulesResponse } from "./modules" + export type UpdateStoreInput = { name?: string swap_link_template?: string @@ -8,3 +12,31 @@ export type UpdateStoreInput = { metadata?: Record default_sales_channel_id?: string } + +/** + * @schema ExtendedStoreDTO + * allOf: + * - $ref: "#/components/schemas/Store" + * - type: object + * required: + * - payment_providers + * - fulfillment_providers + * - feature_flags + * - modules + * properties: + * payment_providers: + * $ref: "#/components/schemas/PaymentProvider" + * fulfillment_providers: + * $ref: "#/components/schemas/FulfillmentProvider" + * feature_flags: + * $ref: "#/components/schemas/FeatureFlagsResponse" + * modules: + * $ref: "#/components/schemas/ModulesResponse" + * + */ +export type ExtendedStoreDTO = Store & { + payment_providers: PaymentProvider[] + fulfillment_providers: FulfillmentProvider[] + feature_flags: FeatureFlagsResponse + modules: ModulesResponse +}