diff --git a/server/src/routes/update-inventory/root.ts b/server/src/routes/update-inventory/root.ts new file mode 100644 index 0000000..9bb66be --- /dev/null +++ b/server/src/routes/update-inventory/root.ts @@ -0,0 +1,58 @@ +import { FastifyPluginAsyncTypebox, TypeBoxTypeProvider } from '@fastify/type-provider-typebox' +import { UpdateInventoryRequestSchema, UpdateInventoryResponseSchema } from 'shared' +import { InvalidRequestError, NotFoundError } from '../../util/errors' +import { ErrorResSchema } from '../../models/errors.model' +import { ServerOptions } from '../../server' +import { + getProductInventoryLocation, + getVariantInventoryLocation, + updateAvailableInventory +} from '../../shopify/inventory' + +// maybe use a generic argument for FastifyPluginAsync if we use options with fastify instance +const updateInventory: FastifyPluginAsyncTypebox = async (fastifyApp, { shopifyClient }): Promise => { + const fastify = fastifyApp.withTypeProvider() + fastify.post('/', { + schema: { + body: UpdateInventoryRequestSchema, + response: { + 200: UpdateInventoryResponseSchema, + 400: ErrorResSchema, + 404: ErrorResSchema + } + } + }, async (request, reply) => { + let inventoryPairs: Array<{ inventoryItemId: string, locationId: string }> | null + + if (request.body.variantId != null) { + inventoryPairs = await getVariantInventoryLocation(shopifyClient, request.body.variantId) + if (inventoryPairs === null) { + throw new NotFoundError('Product variant not found') + } + } else if (request.body.productId != null) { + inventoryPairs = await getProductInventoryLocation(shopifyClient, request.body.productId) + if (inventoryPairs === null) { + throw new NotFoundError('Product not found') + } + } else { + throw new InvalidRequestError('Must specify either productId or variantId') + } + + if (request.body.locationId != null) { + inventoryPairs = inventoryPairs.filter(p => p.locationId === request.body.locationId) + } + if (inventoryPairs.length === 0) { + throw new NotFoundError('Inventory Item not found') + } + if (inventoryPairs.length > 1) { + // Error type + throw new NotFoundError(`Multiple inventories found: ${inventoryPairs.toString()}`) + } + + const response = await updateAvailableInventory(shopifyClient, inventoryPairs[0].inventoryItemId, inventoryPairs[0].locationId, request.body.quantity) + + await reply.status(200).send(response) + }) +} + +export default updateInventory diff --git a/server/src/shopify/inventory.ts b/server/src/shopify/inventory.ts new file mode 100644 index 0000000..e8af1d4 --- /dev/null +++ b/server/src/shopify/inventory.ts @@ -0,0 +1,170 @@ +import { ShopifyGraphQLClient } from './shopify-client' +import { UpdateInventoryResponse, UpdateInventoryResponseSchema } from 'shared' +import { isValid } from '../util/validate' + +const MAX_VARIANTS_COUNT = 10 +const MAX_LOCATIONS_COUNT = 5 + +export const internalVariantQuery = /* GraphQL */ ` +id +inventoryItem { + id + inventoryLevels(first: ${MAX_LOCATIONS_COUNT}) { + nodes { + id + location { + id + name + } + quantities(names: ["available"]) { + name + quantity + } + } + } +} +` + +const internalProductQuery = /* GraphQL */ ` +id +variants(first: ${MAX_VARIANTS_COUNT}) { + nodes { + ${internalVariantQuery} + } +} +` + +const getVariantLocationsQuery = /* GraphQL */ ` + query variantLocations($id: ID!) { + productVariant(id: $id) { + ${internalVariantQuery} + } + } +` + +const getProductLocationsQuery = /* GraphQL */ ` + query productLocations($id: ID!) { + product(id: $id) { + ${internalProductQuery} + } + } +` + +const inventoryItemMutation = /* GraphQL */ ` + mutation inventorySetQuantities($input: InventorySetQuantitiesInput!) { + inventorySetQuantities(input: $input) { + inventoryAdjustmentGroup { + reason + referenceDocumentUri + changes { + name + delta + quantityAfterChange + } + } + userErrors { + code + field + message + } + } + } +` + +interface InventoryLocation { + inventoryItemId: string + locationId: string +} + +export async function getVariantInventoryLocation ( + client: ShopifyGraphQLClient, + variantId: string +): Promise { + const { data, errors } = await client.request(getVariantLocationsQuery, { + id: variantId + }) as { data: Record, errors: unknown } + + if (errors != null) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw errors + } + + // fail fast if there was no match + if (data == null || data.productVariant == null) { + return null + } + + const variant = data.productVariant + const inventoryLocations: InventoryLocation[] = [] + for (const inventoryLevel of variant.inventoryItem.inventoryLevels.nodes) { + inventoryLocations.push({ + inventoryItemId: variant.inventoryItem.id, + locationId: inventoryLevel.location.id + }) + } + + return inventoryLocations +} + +export async function getProductInventoryLocation ( + client: ShopifyGraphQLClient, + productId: string +): Promise { + const { data, errors } = await client.request(getProductLocationsQuery, { + id: productId + }) as { data: Record, errors: unknown } + + if (errors != null) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw errors + } + + // fail fast if there was no match + if (data == null || data.product == null) { + return null + } + + const inventoryLocations: InventoryLocation[] = [] + for (const variant of data.product.variants.nodes) { + for (const inventoryLevel of variant.inventoryItem.inventoryLevels.nodes) { + inventoryLocations.push({ + inventoryItemId: variant.inventoryItem.id, + locationId: inventoryLevel.location.id + }) + } + } + + return inventoryLocations +} + +export async function updateAvailableInventory ( + client: ShopifyGraphQLClient, + inventoryItemId: string, + locationId: string, + quantity: number +): Promise { + const { data, errors } = await client.request(inventoryItemMutation, { + input: { + ignoreCompareQuantity: true, + reason: 'correction', + name: 'available', + quantities: [ + { + inventoryItemId, + locationId, + quantity + } + ] + } + }) as { data: Record, errors: unknown } + + if (errors != null) { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw errors + } + const response: any = data.inventorySetQuantities + if (!isValid(UpdateInventoryResponseSchema, response, 'inventory response')) { + throw new Error('Error mapping response. Changes may have been made.') + } + return response +} diff --git a/server/src/shopify/product.ts b/server/src/shopify/product.ts index 532a39b..4bca506 100644 --- a/server/src/shopify/product.ts +++ b/server/src/shopify/product.ts @@ -3,22 +3,53 @@ import { logger } from '../util/logger' import { ProductSchema, Product, PageInfo, PageInfoSchema } from 'shared' import { isValid } from '../util/validate' -const internalProductQuery = /* GraphQL */ ` +const MAX_VARIANTS_COUNT = 10 +const MAX_LOCATIONS_COUNT = 5 + +export const internalVariantQuery = /* GraphQL */ ` id -handle -variants(first: 100) { - nodes { +title +image { id - title - image { - url - } - contextualPricing(context: {}) { - price { + url +} +sku +displayName +image { + url + id +} +contextualPricing(context: {}) { + price { amount currencyCode - } } +} +inventoryQuantity +inventoryItem { + id + inventoryLevels(first: ${MAX_LOCATIONS_COUNT}) { + nodes { + id + location { + id + name + } + quantities(names: ["available", "committed"]) { + name + quantity + } + } + } +} +` + +const internalProductQuery = /* GraphQL */ ` +id +handle +variants(first: ${MAX_VARIANTS_COUNT}) { + nodes { + ${internalVariantQuery} } } title @@ -58,6 +89,30 @@ const getProductsQuery = /* GraphQL */ ` } ` +function parseProduct (product: any): Product { + let totalAvailableInventory: number = 0 + let totalCommittedInventory: number = 0 + + for (const variant of product.variants.nodes) { + for (const inventoryLevel of variant.inventoryItem.inventoryLevels.nodes) { + for (const inventoryQuantity of inventoryLevel.quantities) { + const quantity: number = inventoryQuantity.quantity + if (inventoryQuantity.name === 'available') { + totalAvailableInventory += quantity + } else if (inventoryQuantity.name === 'committed') { + totalCommittedInventory += quantity + } + } + } + } + product.totalAvailableInventory = totalAvailableInventory + product.totalCommittedInventory = totalCommittedInventory + if (!isValid(ProductSchema, product, 'product')) { + throw new Error('Error mapping product') + } + return product +} + export async function getProductById ( client: ShopifyGraphQLClient, id: string @@ -76,11 +131,7 @@ export async function getProductById ( return null } - const product: unknown = data.product - if (!isValid(ProductSchema, product, 'product')) { - return null - } - return product + return parseProduct(data.product) } export async function getProducts ( @@ -107,11 +158,7 @@ export async function getProducts ( const products: Product[] = [] for (const edge of data.products.edges) { - const product = edge.node - if (!isValid(ProductSchema, product, 'product')) { - throw new Error('Error mapping product') - } - products.push(product) + products.push(parseProduct(edge.node)) } const pageInfo = data.products.pageInfo if (!isValid(PageInfoSchema, pageInfo, 'page info')) { diff --git a/server/src/util/validate.ts b/server/src/util/validate.ts index 24c0057..74c0a89 100644 --- a/server/src/util/validate.ts +++ b/server/src/util/validate.ts @@ -18,7 +18,7 @@ export function isValid (schema: TSchema, data: unknown, descriptorMsg?: stri if (validator.Check(data)) { return true } - const errors = [...validator.Errors(data)].map(err => `\n\t${err.message} but received ${String(err.value)}`) + const errors = [...validator.Errors(data)].map(err => `\n\t${err.message} but received ${String(err.value)} at ${err.path}`) logger.warn(`Errors when validating that ${descriptorMsg ?? 'data'} is of type ${schema.$id != null ? 'type ' + schema.$id : 'correct type'}:`, errors) return false } diff --git a/shared/src/index.ts b/shared/src/index.ts index f753b7d..48c0e8b 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -4,3 +4,4 @@ export * from './model/product.model' export * from './model/variant.model' export * from './model/image.model' export * from './model/response.model' +export * from './model/inventory.model' diff --git a/shared/src/model/inventory.model.ts b/shared/src/model/inventory.model.ts new file mode 100644 index 0000000..9932b7d --- /dev/null +++ b/shared/src/model/inventory.model.ts @@ -0,0 +1,42 @@ +import { Static, Type } from '@fastify/type-provider-typebox' +import { Nullable } from './misc' + +export const VariantInventoryItemSchema = Type.Object({ + id: Type.String(), + inventoryLevels: Type.Object({ + nodes: Type.Array(Type.Object({ + location: Type.Object({ + id: Type.String(), + name: Type.String() + }), + quantities: Type.Array(Type.Object({ + name: Type.String(), + quantity: Type.Integer() + })) + })) + }) +}) +export type VariantInventoryItem = Static + +export const UpdateInventoryRequestSchema = Type.Object({ + productId: Type.Optional(Type.String()), + variantId: Type.Optional(Type.String()), + locationId: Type.Optional(Type.String()), + quantity: Type.Integer() +}) +export type UpdateInventoryRequest = Static + +export const UpdateInventoryResponseSchema = Type.Object({ + inventoryAdjustmentGroup: Nullable(Type.Object({ + reason: Type.String(), + changes: Type.Array(Type.Object({ + name: Type.String(), + delta: Type.Integer() + })) + })), + userErrors: Type.Array(Type.Object({ + field: Type.String(), + message: Type.String() + })) +}) +export type UpdateInventoryResponse = Static diff --git a/shared/src/model/product.model.ts b/shared/src/model/product.model.ts index 079f0df..961ad21 100644 --- a/shared/src/model/product.model.ts +++ b/shared/src/model/product.model.ts @@ -13,7 +13,9 @@ export const ProductSchema = Type.Object({ variants: Type.Object({ nodes: Type.Array(VariantSchema) }), - totalInventory: Type.Integer() + totalInventory: Type.Integer(), + totalAvailableInventory: Type.Integer(), + totalCommittedInventory: Type.Integer() }) export type Product = Static diff --git a/shared/src/model/variant.model.ts b/shared/src/model/variant.model.ts index dc32978..81c4344 100644 --- a/shared/src/model/variant.model.ts +++ b/shared/src/model/variant.model.ts @@ -1,6 +1,7 @@ import { Static, Type } from '@fastify/type-provider-typebox' import { ImageObjSchema } from './image.model' import { Nullable } from './misc' +import { VariantInventoryItemSchema } from './inventory.model' export const VariantSchema = Type.Object({ id: Type.String(), @@ -11,6 +12,8 @@ export const VariantSchema = Type.Object({ amount: Type.String(), currencyCode: Type.String() }) - }) + }), + sku: Nullable(Type.String()), + inventoryItem: VariantInventoryItemSchema }) export type Variant = Static