Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inventory API #19

Merged
merged 5 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions server/src/routes/update-inventory/root.ts
Original file line number Diff line number Diff line change
@@ -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<ServerOptions> = async (fastifyApp, { shopifyClient }): Promise<void> => {
const fastify = fastifyApp.withTypeProvider<TypeBoxTypeProvider>()
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
170 changes: 170 additions & 0 deletions server/src/shopify/inventory.ts
Original file line number Diff line number Diff line change
@@ -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<InventoryLocation[] | null> {
const { data, errors } = await client.request(getVariantLocationsQuery, {
id: variantId
}) as { data: Record<string, any>, 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<InventoryLocation[] | null> {
const { data, errors } = await client.request(getProductLocationsQuery, {
id: productId
}) as { data: Record<string, any>, 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<UpdateInventoryResponse> {
const { data, errors } = await client.request(inventoryItemMutation, {
input: {
ignoreCompareQuantity: true,
reason: 'correction',
name: 'available',
quantities: [
{
inventoryItemId,
locationId,
quantity
}
]
}
}) as { data: Record<string, unknown>, errors: unknown }

if (errors != null) {
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw errors
}
const response: any = data.inventorySetQuantities
if (!isValid<UpdateInventoryResponse>(UpdateInventoryResponseSchema, response, 'inventory response')) {
throw new Error('Error mapping response. Changes may have been made.')
}
return response
}
89 changes: 68 additions & 21 deletions server/src/shopify/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Product>(ProductSchema, product, 'product')) {
throw new Error('Error mapping product')
}
return product
}

export async function getProductById (
client: ShopifyGraphQLClient,
id: string
Expand All @@ -76,11 +131,7 @@ export async function getProductById (
return null
}

const product: unknown = data.product
if (!isValid<Product>(ProductSchema, product, 'product')) {
return null
}
return product
return parseProduct(data.product)
}

export async function getProducts (
Expand All @@ -107,11 +158,7 @@ export async function getProducts (

const products: Product[] = []
for (const edge of data.products.edges) {
const product = edge.node
if (!isValid<Product>(ProductSchema, product, 'product')) {
throw new Error('Error mapping product')
}
products.push(product)
products.push(parseProduct(edge.node))
}
const pageInfo = data.products.pageInfo
if (!isValid<PageInfo>(PageInfoSchema, pageInfo, 'page info')) {
Expand Down
2 changes: 1 addition & 1 deletion server/src/util/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function isValid<T> (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
}
1 change: 1 addition & 0 deletions shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading
Loading