JUNOHUB-test/src/lib/shopify/productMetafieldsGraphql.ts

172 lines
5.7 KiB
TypeScript

import { cleanShopifyDomain } from "@/lib/shopify/adminPing"
/** One metafield row stored in CachedProduct.metafields JSON (category attributes + custom). */
export type CachedShopifyMetafield = {
namespace: string
key: string
value: string | null
type: string
}
type GraphQLMetaobjectRef = {
displayName?: string | null
} | null
type GraphQLMetafieldNode = {
namespace?: string
key?: string
value?: string | null
type?: string
reference?: GraphQLMetaobjectRef
references?: {
nodes?: GraphQLMetaobjectRef[]
} | null
}
type GraphQLProductNode = {
legacyResourceId?: string | number | null
metafields?: {
nodes?: Array<GraphQLMetafieldNode | null> | null
} | null
} | null
type GraphQLResponse = {
data?: { nodes?: GraphQLProductNode[] | null }
errors?: Array<{ message?: string }>
}
const GRAPHQL_API_VERSION = "2024-10"
/** Keep batches small to stay under GraphQL cost limits (many metafields per product). */
const PRODUCT_IDS_PER_REQUEST = 35
/** Max entries per list.metaobject_reference (taxonomy lists are usually small). */
const METAFIELD_REFERENCES_FIRST = 50
function displayNameFromRef(ref: GraphQLMetaobjectRef): string | undefined {
if (!ref || typeof ref !== "object") return undefined
const d = ref.displayName
if (typeof d !== "string") return undefined
const t = d.trim()
return t.length > 0 ? t : undefined
}
/**
* Shopify stores metaobject metafields as GID(s) in `value`. Replace with human-readable
* labels from GraphQL `reference` / `references` when present.
*/
function resolvedMetafieldValue(n: GraphQLMetafieldNode, raw: string | null, type: string): string | null {
if (raw === null) return null
if (type === "metaobject_reference") {
const label = displayNameFromRef(n.reference ?? null)
return label ?? raw
}
if (type === "list.metaobject_reference") {
const refNodes = n.references?.nodes
if (!Array.isArray(refNodes) || refNodes.length === 0) return raw
const labels = refNodes.map((r) => displayNameFromRef(r)).filter((x): x is string => Boolean(x))
if (labels.length === 0) return raw
return JSON.stringify(labels)
}
return raw
}
function toRows(metafields: NonNullable<GraphQLProductNode>["metafields"]): CachedShopifyMetafield[] {
const nodes = metafields?.nodes
if (!Array.isArray(nodes)) return []
const out: CachedShopifyMetafield[] = []
for (const n of nodes) {
if (!n || typeof n.namespace !== "string" || typeof n.key !== "string") continue
const type = typeof n.type === "string" ? n.type : "unknown"
const raw = n.value ?? null
out.push({
namespace: n.namespace,
key: n.key,
value: resolvedMetafieldValue(n, raw, type),
type,
})
}
return out
}
/**
* Fetches product metafields via Admin GraphQL (`nodes` query).
* Covers category / taxonomy metafields (often `shopify.*` keys) shown in Shopify admin.
*/
export async function fetchProductMetafieldsMap(
shopifyDomain: string,
accessToken: string,
productNumericIds: number[],
): Promise<Map<string, CachedShopifyMetafield[]>> {
const result = new Map<string, CachedShopifyMetafield[]>()
const cleanDomain = cleanShopifyDomain(shopifyDomain)
if (!cleanDomain || !accessToken || productNumericIds.length === 0) {
return result
}
const url = `https://${cleanDomain}/admin/api/${GRAPHQL_API_VERSION}/graphql.json`
for (let i = 0; i < productNumericIds.length; i += PRODUCT_IDS_PER_REQUEST) {
const chunk = productNumericIds.slice(i, i + PRODUCT_IDS_PER_REQUEST)
const gidList = chunk.map((id) => `"gid://shopify/Product/${id}"`).join(", ")
const query = `
query ProductMetafieldsChunk {
nodes(ids: [${gidList}]) {
... on Product {
legacyResourceId
metafields(first: 250) {
nodes {
namespace
key
value
type
reference {
... on Metaobject {
displayName
}
}
references(first: ${METAFIELD_REFERENCES_FIRST}) {
nodes {
... on Metaobject {
displayName
}
}
}
}
}
}
}
}
`
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": accessToken,
},
body: JSON.stringify({ query }),
})
const json = (await res.json()) as GraphQLResponse
if (!res.ok) {
throw new Error(`Shopify GraphQL HTTP ${res.status}`)
}
if (json.errors?.length) {
throw new Error(json.errors.map((e) => e.message).filter(Boolean).join("; ") || "GraphQL error")
}
const nodes = json.data?.nodes
if (!Array.isArray(nodes)) continue
for (const node of nodes) {
if (!node || node.legacyResourceId == null) continue
const idStr = String(node.legacyResourceId)
result.set(idStr, toRows(node.metafields))
}
}
return result
}