import { assertNoUserErrors, clampInt, nodesFromConnection, pageInfoFromConnection, shopifyGraphql, trimString, } from './lib/client.ts' import { PRODUCT_FIELDS, PAGE_INFO_FIELDS } from './lib/fragments.ts' import { toGid } from './lib/shop.ts' import type { DryRunResult, ShopifyClientOptions, ShopifyPageInfo } from './types.ts' export type ProductVariantSummary = { id: string title: string | null sku: string | null barcode: string | null price: string | null compareAtPrice: string | null inventoryQuantity: number | null } export type ProductSummary = { id: string title: string handle: string | null status: string | null vendor: string | null productType: string | null tags: Array createdAt: string | null updatedAt: string | null totalInventory: number | null description: string | null variants: Array } export type ListProductsParams = ShopifyClientOptions & { /** Shopify search query, e.g. `status:active title:backpack`. */ query?: string first?: number after?: string } function mapVariant(node: Record): ProductVariantSummary { return { id: String(node.id || ''), title: typeof node.title === 'string' ? node.title : null, sku: typeof node.sku === 'string' ? node.sku : null, barcode: typeof node.barcode === 'string' ? node.barcode : null, price: typeof node.price === 'string' ? node.price : null, compareAtPrice: typeof node.compareAtPrice === 'string' ? node.compareAtPrice : null, inventoryQuantity: typeof node.inventoryQuantity === 'number' ? node.inventoryQuantity : null, } } function mapProduct(node: Record): ProductSummary { return { id: String(node.id || ''), title: String(node.title || ''), handle: typeof node.handle === 'string' ? node.handle : null, status: typeof node.status === 'string' ? node.status : null, vendor: typeof node.vendor === 'string' ? node.vendor : null, productType: typeof node.productType === 'string' ? node.productType : null, tags: Array.isArray(node.tags) ? node.tags.map(String) : [], createdAt: typeof node.createdAt === 'string' ? node.createdAt : null, updatedAt: typeof node.updatedAt === 'string' ? node.updatedAt : null, totalInventory: typeof node.totalInventory === 'number' ? node.totalInventory : null, description: typeof node.description === 'string' ? node.description : null, variants: nodesFromConnection>(node.variants).map( mapVariant, ), } } /** * List products with optional Shopify search syntax. * * @example * import { listProducts } from 'kody:@kody/shopify/products' * const { items } = await listProducts({ shop: 'acme', query: 'status:active', first: 10 }) */ export async function listProducts(params: ListProductsParams = {}) { const first = clampInt(params.first, 1, 50, 20) const data = await shopifyGraphql<{ products: unknown }>({ shop: params.shop, apiVersion: params.apiVersion, query: `query ListProducts($first: Int!, $after: String, $query: String) { products(first: $first, after: $after, query: $query) { ${PAGE_INFO_FIELDS} nodes { ${PRODUCT_FIELDS} } } }`, variables: { first, after: trimString(params.after) || null, query: trimString(params.query) || null, }, }) return { items: nodesFromConnection>(data.products).map( mapProduct, ), pageInfo: pageInfoFromConnection(data.products) as ShopifyPageInfo, } } /** * Get one product by GID or numeric id. * * @example * import { getProduct } from 'kody:@kody/shopify/products' * const product = await getProduct({ shop: 'acme', id: '123' }) */ export async function getProduct( params: ShopifyClientOptions & { id: string }, ): Promise { const id = toGid('Product', params.id) const data = await shopifyGraphql<{ product: Record | null }>( { shop: params.shop, apiVersion: params.apiVersion, query: `query GetProduct($id: ID!) { product(id: $id) { ${PRODUCT_FIELDS} } }`, variables: { id }, }, ) if (!data.product) throw new Error(`Product not found: ${id}`) return mapProduct(data.product) } export type CreateProductParams = ShopifyClientOptions & { title: string descriptionHtml?: string vendor?: string productType?: string tags?: Array | string status?: 'ACTIVE' | 'DRAFT' | 'ARCHIVED' | 'UNLISTED' /** Preview the mutation payload without writing. */ dryRun?: boolean } /** * Create a product. Defaults to dry-run; pass `dryRun: false` to write. * * @example * import { createProduct } from 'kody:@kody/shopify/products' * const preview = await createProduct({ shop: 'acme', title: 'Hiking backpack' }) */ export async function createProduct(params: CreateProductParams) { const title = trimString(params.title) if (!title) throw new Error('title is required.') const product = { title, descriptionHtml: trimString(params.descriptionHtml) || undefined, vendor: trimString(params.vendor) || undefined, productType: trimString(params.productType) || undefined, tags: Array.isArray(params.tags) ? params.tags : trimString(params.tags) ? trimString(params.tags) .split(',') .map((tag) => tag.trim()) .filter(Boolean) : undefined, status: params.status, } if (params.dryRun !== false) { const preview: DryRunResult<{ mutation: string; product: typeof product }> = { dryRun: true, wouldCall: { mutation: 'productCreate', product }, } return preview } const data = await shopifyGraphql<{ productCreate: unknown }>({ shop: params.shop, apiVersion: params.apiVersion, query: `mutation CreateProduct($product: ProductCreateInput!) { productCreate(product: $product) { product { ${PRODUCT_FIELDS} } userErrors { field message } } }`, variables: { product }, }) assertNoUserErrors(data.productCreate, 'productCreate') const created = (data.productCreate as { product?: Record }) .product if (!created) throw new Error('productCreate returned no product.') return mapProduct(created) } export type UpdateProductParams = ShopifyClientOptions & { id: string title?: string descriptionHtml?: string vendor?: string productType?: string tags?: Array | string status?: 'ACTIVE' | 'DRAFT' | 'ARCHIVED' | 'UNLISTED' dryRun?: boolean } /** * Update a product. Defaults to dry-run; pass `dryRun: false` to write. */ export async function updateProduct(params: UpdateProductParams) { const id = toGid('Product', params.id) const product = { title: trimString(params.title) || undefined, descriptionHtml: trimString(params.descriptionHtml) || undefined, vendor: trimString(params.vendor) || undefined, productType: trimString(params.productType) || undefined, tags: Array.isArray(params.tags) ? params.tags : trimString(params.tags) ? trimString(params.tags) .split(',') .map((tag) => tag.trim()) .filter(Boolean) : undefined, status: params.status, } if (params.dryRun !== false) { return { dryRun: true as const, wouldCall: { mutation: 'productUpdate', id, product }, } } const data = await shopifyGraphql<{ productUpdate: unknown }>({ shop: params.shop, apiVersion: params.apiVersion, query: `mutation UpdateProduct($input: ProductUpdateInput!) { productUpdate(product: $input) { product { ${PRODUCT_FIELDS} } userErrors { field message } } }`, variables: { input: { id, ...product } }, }) assertNoUserErrors(data.productUpdate, 'productUpdate') const updated = (data.productUpdate as { product?: Record }) .product if (!updated) throw new Error('productUpdate returned no product.') return mapProduct(updated) } /** Default export: list products. */ export default listProducts