Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/shopify

src/products.ts

254 lines · 7.6 KB · TypeScript
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<string>
	createdAt: string | null
	updatedAt: string | null
	totalInventory: number | null
	description: string | null
	variants: Array<ProductVariantSummary>
}

export type ListProductsParams = ShopifyClientOptions & {
	/** Shopify search query, e.g. `status:active title:backpack`. */
	query?: string
	first?: number
	after?: string
}

function mapVariant(node: Record<string, unknown>): 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<string, unknown>): 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<Record<string, unknown>>(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<Record<string, unknown>>(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<ProductSummary> {
	const id = toGid('Product', params.id)
	const data = await shopifyGraphql<{ product: Record<string, unknown> | 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> | 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<string, unknown> })
		.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> | 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<string, unknown> })
		.product
	if (!updated) throw new Error('productUpdate returned no product.')
	return mapProduct(updated)
}

/** Default export: list products. */
export default listProducts