Skip to content

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

Package listing

@kody/shopify

src/customers.ts

186 lines · 5.6 KB · TypeScript
import {
	assertNoUserErrors,
	clampInt,
	nodesFromConnection,
	pageInfoFromConnection,
	shopifyGraphql,
	trimString,
} from './lib/client.ts'
import { CUSTOMER_FIELDS, PAGE_INFO_FIELDS } from './lib/fragments.ts'
import { toGid } from './lib/shop.ts'
import type { Money, ShopifyClientOptions, ShopifyPageInfo } from './types.ts'

export type CustomerSummary = {
	id: string
	displayName: string | null
	firstName: string | null
	lastName: string | null
	email: string | null
	phone: string | null
	createdAt: string | null
	updatedAt: string | null
	numberOfOrders: string | number | null
	amountSpent: Money | null
	defaultAddress: {
		id: string | null
		formatted: Array<string>
		city: string | null
		province: string | null
		country: string | null
		zip: string | null
	} | null
}

export type ListCustomersParams = ShopifyClientOptions & {
	query?: string
	first?: number
	after?: string
}

function mapMoney(value: unknown): Money | null {
	if (!value || typeof value !== 'object') return null
	const amount = trimString((value as { amount?: unknown }).amount)
	const currencyCode = trimString(
		(value as { currencyCode?: unknown }).currencyCode,
	)
	if (!amount || !currencyCode) return null
	return { amount, currencyCode }
}

function mapCustomer(node: Record<string, unknown>): CustomerSummary {
	const address =
		node.defaultAddress && typeof node.defaultAddress === 'object'
			? (node.defaultAddress as Record<string, unknown>)
			: null
	return {
		id: String(node.id || ''),
		displayName: typeof node.displayName === 'string' ? node.displayName : null,
		firstName: typeof node.firstName === 'string' ? node.firstName : null,
		lastName: typeof node.lastName === 'string' ? node.lastName : null,
		email: typeof node.email === 'string' ? node.email : null,
		phone: typeof node.phone === 'string' ? node.phone : null,
		createdAt: typeof node.createdAt === 'string' ? node.createdAt : null,
		updatedAt: typeof node.updatedAt === 'string' ? node.updatedAt : null,
		numberOfOrders:
			typeof node.numberOfOrders === 'number' ||
			typeof node.numberOfOrders === 'string'
				? node.numberOfOrders
				: null,
		amountSpent: mapMoney(node.amountSpent),
		defaultAddress: address
			? {
					id: typeof address.id === 'string' ? address.id : null,
					formatted: Array.isArray(address.formatted)
						? address.formatted.map(String)
						: [],
					city: typeof address.city === 'string' ? address.city : null,
					province: typeof address.province === 'string' ? address.province : null,
					country: typeof address.country === 'string' ? address.country : null,
					zip: typeof address.zip === 'string' ? address.zip : null,
				}
			: null,
	}
}

/**
 * List customers with optional Shopify search syntax.
 *
 * @example
 * import { listCustomers } from 'kody:@kody/shopify/customers'
 * const { items } = await listCustomers({ shop: 'acme', query: 'email:ada@example.com' })
 */
export async function listCustomers(params: ListCustomersParams = {}) {
	const first = clampInt(params.first, 1, 50, 20)
	const data = await shopifyGraphql<{ customers: unknown }>({
		shop: params.shop,
		apiVersion: params.apiVersion,
		query: `query ListCustomers($first: Int!, $after: String, $query: String) {
			customers(first: $first, after: $after, query: $query) {
				${PAGE_INFO_FIELDS}
				nodes { ${CUSTOMER_FIELDS} }
			}
		}`,
		variables: {
			first,
			after: trimString(params.after) || null,
			query: trimString(params.query) || null,
		},
	})
	return {
		items: nodesFromConnection<Record<string, unknown>>(data.customers).map(
			mapCustomer,
		),
		pageInfo: pageInfoFromConnection(data.customers) as ShopifyPageInfo,
	}
}

/**
 * Get one customer by GID or numeric id.
 */
export async function getCustomer(
	params: ShopifyClientOptions & { id: string },
): Promise<CustomerSummary> {
	const id = toGid('Customer', params.id)
	const data = await shopifyGraphql<{
		customer: Record<string, unknown> | null
	}>({
		shop: params.shop,
		apiVersion: params.apiVersion,
		query: `query GetCustomer($id: ID!) {
			customer(id: $id) { ${CUSTOMER_FIELDS} }
		}`,
		variables: { id },
	})
	if (!data.customer) throw new Error(`Customer not found: ${id}`)
	return mapCustomer(data.customer)
}

export type CreateCustomerParams = ShopifyClientOptions & {
	email?: string
	firstName?: string
	lastName?: string
	phone?: string
	note?: string
	dryRun?: boolean
}

/**
 * Create a customer. Defaults to dry-run; pass `dryRun: false` to write.
 */
export async function createCustomer(params: CreateCustomerParams) {
	const input = {
		email: trimString(params.email) || undefined,
		firstName: trimString(params.firstName) || undefined,
		lastName: trimString(params.lastName) || undefined,
		phone: trimString(params.phone) || undefined,
		note: trimString(params.note) || undefined,
	}
	if (!input.email && !input.phone) {
		throw new Error('createCustomer requires email or phone.')
	}
	if (params.dryRun !== false) {
		return {
			dryRun: true as const,
			wouldCall: { mutation: 'customerCreate', input },
		}
	}
	const data = await shopifyGraphql<{ customerCreate: unknown }>({
		shop: params.shop,
		apiVersion: params.apiVersion,
		query: `mutation CreateCustomer($input: CustomerInput!) {
			customerCreate(input: $input) {
				customer { ${CUSTOMER_FIELDS} }
				userErrors { field message }
			}
		}`,
		variables: { input },
	})
	assertNoUserErrors(data.customerCreate, 'customerCreate')
	const created = (data.customerCreate as { customer?: Record<string, unknown> })
		.customer
	if (!created) throw new Error('customerCreate returned no customer.')
	return mapCustomer(created)
}

/** Default export: list customers. */
export default listCustomers