import refreshTokenHelper from "../../../utils/refreshTokenHelper" import errorHandlingHelper from "../../../utils/errorHandlingHelper" import fetchHelper from "../../../utils/fetchHelper" import { getShopifyNewAccessToken, shopifyNewGraphQL, parseShopifyGid } from "../../../utils/shopifyNewAuthHelper" // POST /api/shopify-new/stocks // Body: { orderSourceId, variantIds (comma-separated or array), productIds (comma-separated "pid:key" or object) } // Fetches current stock from Shopify via GraphQL Admin API v2025-10 // Returns same response shape as /api/shopify/stocks for frontend compatibility // Uses POST to avoid URL length limits with large SKU lists const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const body = await readBody(event) const orderSourceId = body.orderSourceId as string const variantIdsParam = body.variantIds as string const productIdsParam = (body.productIds as string) || '' if(!orderSourceId) { return { status: 400, message: 'orderSourceId is required' } } if(!variantIdsParam) { return { status: 400, message: 'variantIds is required' } } const skus = variantIdsParam.split(',').map(id => id.trim()).filter(Boolean) if(skus.length === 0) { return { status: 400, message: 'No valid SKUs provided' } } // Parse productIds param: "shopifyProductId:localKey,shopifyProductId2:localKey2" // Maps Shopify product numeric ID -> local key (SKU) for fallback matching const productIdToLocalKey: Record = {} if(productIdsParam) { for(const entry of productIdsParam.split(',')) { const colonIdx = entry.indexOf(':') if(colonIdx > 0) { const pid = entry.substring(0, colonIdx).trim() const localKey = entry.substring(colonIdx + 1).trim() if(pid && localKey) { productIdToLocalKey[pid] = localKey } } } } // Load order source to verify it's shopify-new and get credentials const os: any = await fetchHelper(event, `models/c_ordersource/${orderSourceId}`, 'GET', token, null) if(!os) { return { status: 404, message: 'Order Source not found' } } if(os?.Marketplace?.identifier !== 'shopify-new') { return { status: 400, message: 'Selected Order Source is not Shopify-New' } } try { const { accessToken, graphqlUrl } = await getShopifyNewAccessToken(os) const stocks: Record = {} const inventoryItemIds: Record = {} const errors: Record = {} const skuSet = new Set(skus.map(s => s.toLowerCase())) // Initialize all requested SKUs with 0 for(const sku of skus) { stocks[sku] = 0 } // Step 1: Fetch all locations const locationsMap: Record = {} try { const locationsQuery = `{ locations(first: 50) { edges { node { id name isActive } } } }` const locRes = await shopifyNewGraphQL(graphqlUrl, accessToken, locationsQuery) const locationEdges = locRes?.data?.locations?.edges || [] for(const edge of locationEdges) { const node = edge.node const numericId = parseShopifyGid(node.id) locationsMap[numericId] = { id: numericId, name: node.name || `Location ${numericId}`, active: node.isActive ?? true } } console.log(`shopify-new: Found ${Object.keys(locationsMap).length} locations`) } catch (locErr: any) { console.log('shopify-new: Could not fetch locations:', locErr?.message) } // Step 2: Look up variants by SKU in batches using GraphQL aliases // Each alias queries productVariants(first:1, query:"sku:...") with nested inventoryLevels // Batches of ~40 SKUs keep query cost under 1000 (each alias costs ~11 points: 1 + 1*10) const skuInventoryMap: Record, total: number }> = {} const BATCH_SIZE = 40 const skuBatches: string[][] = [] for(let i = 0; i < skus.length; i += BATCH_SIZE) { skuBatches.push(skus.slice(i, i + BATCH_SIZE)) } console.log(`shopify-new: Looking up ${skus.length} SKUs in ${skuBatches.length} batch(es)`) // Fragment for variant fields to keep the query readable const variantFragment = ` fragment VariantFields on ProductVariant { id sku inventoryItem { id inventoryLevels(first: 10) { edges { node { location { id name } quantities(names: "available") { quantity } } } } } } ` for(let batchIdx = 0; batchIdx < skuBatches.length; batchIdx++) { const batch = skuBatches[batchIdx] try { // Build aliased query: each alias looks up one SKU const aliasQueries = batch.map((sku, idx) => { // Escape double quotes in SKU for the query string const escapedSku = sku.replace(/"/g, '\\"') return `v${idx}: productVariants(first: 1, query: "sku:\\"${escapedSku}\\"") { edges { node { ...VariantFields } } }` }).join('\n') const batchQuery = `${variantFragment}\n{ ${aliasQueries} }` console.log(`shopify-new: Batch ${batchIdx + 1}/${skuBatches.length} (${batch.length} SKUs)...`) const res = await shopifyNewGraphQL(graphqlUrl, accessToken, batchQuery) // Check rate limiting const available = res?.extensions?.cost?.throttleStatus?.currentlyAvailable if(available !== undefined && available < 100) { console.log(`shopify-new: Rate limit low (${available} points), sleeping 2s...`) await new Promise(resolve => setTimeout(resolve, 2000)) } // Parse each alias response for(let idx = 0; idx < batch.length; idx++) { const sku = batch[idx] const aliasData = res?.data?.[`v${idx}`] const variantEdges = aliasData?.edges || [] if(variantEdges.length === 0) continue const variant = variantEdges[0].node const variantSku = String(variant.sku || '').toLowerCase() const inventoryItem = variant.inventoryItem const inventoryItemGid = inventoryItem?.id || '' const levelEdges = inventoryItem?.inventoryLevels?.edges || [] const stockByLocation: Record = {} let total = 0 for(const levelEdge of levelEdges) { const level = levelEdge.node const locationGid = level.location?.id || '' const locationNumericId = parseShopifyGid(locationGid) const locationName = level.location?.name || locationsMap[locationNumericId]?.name || `Location ${locationNumericId}` const qty = Number(level.quantities?.[0]?.quantity ?? 0) stockByLocation[locationNumericId] = { qty, name: locationName } total += qty } // Use the original SKU case (as sent from frontend) for the key const mapKey = variantSku || sku.toLowerCase() skuInventoryMap[mapKey] = { inventoryItemGid, stockByLocation, total } } } catch (err: any) { console.error(`shopify-new: Batch ${batchIdx + 1} failed:`, err?.message || err) } } console.log(`shopify-new: Built SKU inventory map with ${Object.keys(skuInventoryMap).length} entries`) // Step 3: Map back to requested SKUs const stocksByLocation: Record> = {} for(const sku of skus) { const skuLower = sku.toLowerCase() const mapping = skuInventoryMap[skuLower] if(mapping) { inventoryItemIds[sku] = mapping.inventoryItemGid stocks[sku] = mapping.total if(Object.keys(mapping.stockByLocation).length > 0) { stocksByLocation[sku] = mapping.stockByLocation } } else { errors[sku] = 'SKU not found in Shopify' } } return { status: 200, stocks, stocksByLocation, inventoryItemIds, locations: locationsMap, errors: Object.keys(errors).length > 0 ? errors : undefined, skuCount: skus.length, matchedCount: Object.keys(skuInventoryMap).length } } catch (err: any) { console.error('shopify-new stocks error:', err) const data = errorHandlingHelper(err?.data ?? err, err?.data ?? err) return { status: Number(data?.status || 500), message: data?.message || err?.message || 'Failed to fetch Shopify-New stocks' } } } export default defineEventHandler(async (event) => { try { return await handleFunc(event) } catch (err: any) { try { const authToken = await refreshTokenHelper(event) return await handleFunc(event, authToken) } catch (error: any) { const data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) if([401, 402, 403, 407].includes(Number(data.status))) { //@ts-ignore setCookie(event, 'user', null) } return data } } })