Skip to content
← Public packages

@kentcdodds/kit

Kit.com helpers for subscribers, tags, forms, sequences, and broadcasts.

src/subscribers.js

273 lines · 9.6 KB · JavaScript
/**
 * Subscriber, tag, form, and segment helpers — the waitlist/signup surface.
 *
 * Typical waitlist flow (one call): `subscribeAndTag({ email_address,
 * first_name, tagName })` creates/updates the subscriber and applies the tag,
 * creating the tag when it does not exist yet.
 */
import {
	callParsed,
	listAll,
	subscriberSummary,
	deleteV4TagsTagIdSubscribersId,
	getV4Forms,
	getV4Segments,
	getV4Subscribers,
	getV4SubscribersId,
	getV4SubscribersSubscriberIdTags,
	getV4Tags,
	getV4TagsTagIdSubscribers,
	postV4FormsFormIdSubscribers,
	postV4Subscribers,
	postV4SubscribersIdUnsubscribe,
	postV4Tags,
	postV4TagsTagIdSubscribers,
	putV4SubscribersId,
} from './kit-client.js'

export { subscriberSummary }

/**
 * Create a subscriber, or update the existing one for that email (Kit upserts
 * on email address).
 * @param {{ email_address: string, first_name?: string, state?: string, fields?: Record<string, string> }} input
 * @example
 * const sub = await createSubscriber({ email_address: 'a@b.com', first_name: 'Ada' })
 * // => { id, email_address, first_name, state: 'active', ... }
 */
export async function createSubscriber(input) {
	if (!input?.email_address) throw new Error('createSubscriber: email_address is required')
	const body = await callParsed(
		postV4Subscribers,
		{ body: input },
		{ method: 'POST', path: '/v4/subscribers' },
	)
	const subscriber = /** @type {{ subscriber?: Record<string, unknown> }} */ (body).subscriber
	return { ...subscriberSummary(subscriber), fields: subscriber?.fields }
}

/** @param {number | string} id */
export async function getSubscriber(id) {
	if (!id) throw new Error('getSubscriber: id is required')
	const body = await callParsed(
		getV4SubscribersId,
		{ params: { id } },
		{ method: 'GET', path: `/v4/subscribers/${id}` },
	)
	const subscriber = /** @type {{ subscriber?: Record<string, unknown> }} */ (body).subscriber
	return { ...subscriberSummary(subscriber), fields: subscriber?.fields }
}

/**
 * Look up one subscriber by exact email address. Returns null when not found.
 * @param {string} emailAddress
 */
export async function getSubscriberByEmail(emailAddress) {
	if (!emailAddress) throw new Error('getSubscriberByEmail: emailAddress is required')
	const body = await callParsed(
		getV4Subscribers,
		{ query: { email_address: emailAddress } },
		{ method: 'GET', path: '/v4/subscribers' },
	)
	const subscribers = /** @type {{ subscribers?: Record<string, unknown>[] }} */ (body).subscribers
	const match = Array.isArray(subscribers) ? subscribers[0] : undefined
	return match ? { ...subscriberSummary(match), fields: match.fields } : null
}

/**
 * @param {number | string} id
 * @param {{ email_address?: string, first_name?: string, fields?: Record<string, string> }} fields
 */
export async function updateSubscriber(id, fields) {
	if (!id) throw new Error('updateSubscriber: id is required')
	const body = await callParsed(
		putV4SubscribersId,
		{ params: { id }, body: fields },
		{ method: 'PUT', path: `/v4/subscribers/${id}` },
	)
	const subscriber = /** @type {{ subscriber?: Record<string, unknown> }} */ (body).subscriber
	return { ...subscriberSummary(subscriber), fields: subscriber?.fields }
}

/** @param {number | string} id */
export async function unsubscribeSubscriber(id) {
	if (!id) throw new Error('unsubscribeSubscriber: id is required')
	await callParsed(
		postV4SubscribersIdUnsubscribe,
		{ params: { id }, body: {} },
		{ method: 'POST', path: `/v4/subscribers/${id}/unsubscribe` },
	)
	return { id, unsubscribed: true }
}

/**
 * List subscribers. Pass `maxItems` — full history on a large account is slow.
 * Useful filters: `email_address`, `status`, `created_after`, `sort_order`.
 * @param {Record<string, string | number | boolean | null | undefined> & { maxItems?: number }} [query]
 */
export async function listSubscribers(query = {}) {
	const { maxItems, ...rest } = query
	const subscribers = await listAll(getV4Subscribers, 'subscribers', rest, { maxItems })
	return subscribers
		.map((s) => subscriberSummary(/** @type {Record<string, unknown>} */ (s)))
		.filter(Boolean)
}

/** @param {number | string} subscriberId */
export async function listSubscriberTags(subscriberId) {
	if (!subscriberId) throw new Error('listSubscriberTags: subscriberId is required')
	return listAll(
		(input) =>
			getV4SubscribersSubscriberIdTags({
				...input,
				params: { subscriber_id: subscriberId, ...(input?.params ?? {}) },
			}),
		'tags',
	)
}

/**
 * @param {{ maxItems?: number }} [query]
 * @returns {Promise<Array<{ id: number, name: string }>>}
 */
export async function listTags(query = {}) {
	const { maxItems } = query
	return /** @type {Promise<Array<{ id: number, name: string }>>} */ (
		listAll(getV4Tags, 'tags', {}, { maxItems })
	)
}

/** @param {string} name */
export async function createTag(name) {
	if (!name) throw new Error('createTag: name is required')
	const body = await callParsed(
		postV4Tags,
		{ body: { name } },
		{ method: 'POST', path: '/v4/tags' },
	)
	return /** @type {{ tag?: { id: number, name: string } }} */ (body).tag
}

/**
 * Return the tag with this exact name, creating it when missing.
 * @param {string} name
 * @returns {Promise<{ id: number, name: string }>}
 */
export async function ensureTag(name) {
	if (!name) throw new Error('ensureTag: name is required')
	const tags = await listTags()
	const existing = tags.find((tag) => tag.name === name)
	if (existing) return existing
	const created = await createTag(name)
	if (!created?.id) throw new Error(`ensureTag: Kit returned no tag id for "${name}"`)
	return created
}

/**
 * Tag a subscriber by email address. The subscriber must already exist.
 * @param {number | string} tagId
 * @param {string} emailAddress
 */
export async function tagSubscriberByEmail(tagId, emailAddress) {
	if (!tagId) throw new Error('tagSubscriberByEmail: tagId is required')
	if (!emailAddress) throw new Error('tagSubscriberByEmail: emailAddress is required')
	const body = await callParsed(
		postV4TagsTagIdSubscribers,
		{ params: { tag_id: tagId }, body: { email_address: emailAddress } },
		{ method: 'POST', path: `/v4/tags/${tagId}/subscribers` },
	)
	const subscriber = /** @type {{ subscriber?: Record<string, unknown> }} */ (body).subscriber
	return subscriberSummary(subscriber)
}

/**
 * @param {number | string} tagId
 * @param {number | string} subscriberId
 */
export async function untagSubscriber(tagId, subscriberId) {
	if (!tagId) throw new Error('untagSubscriber: tagId is required')
	if (!subscriberId) throw new Error('untagSubscriber: subscriberId is required')
	await callParsed(
		deleteV4TagsTagIdSubscribersId,
		{ params: { tag_id: tagId, id: subscriberId } },
		{ method: 'DELETE', path: `/v4/tags/${tagId}/subscribers/${subscriberId}` },
	)
	return { tagId, subscriberId, untagged: true }
}

/**
 * List subscribers with a tag. Pass `maxItems` to cap pagination.
 * @param {number | string} tagId
 * @param {Record<string, string | number | boolean | null | undefined> & { maxItems?: number }} [query]
 */
export async function listTagSubscribers(tagId, query = {}) {
	if (!tagId) throw new Error('listTagSubscribers: tagId is required')
	const { maxItems, ...rest } = query
	const subscribers = await listAll(
		(input) =>
			getV4TagsTagIdSubscribers({
				...input,
				params: { tag_id: tagId, ...(input?.params ?? {}) },
			}),
		'subscribers',
		rest,
		{ maxItems },
	)
	return subscribers
		.map((s) => subscriberSummary(/** @type {Record<string, unknown>} */ (s)))
		.filter(Boolean)
}

/**
 * One-call waitlist signup: upsert the subscriber, then apply the tag
 * (created on first use when `tagName` is given).
 * @param {{ email_address: string, first_name?: string, fields?: Record<string, string>, tagName?: string, tagId?: number | string }} input
 * @example
 * await subscribeAndTag({ email_address: 'a@b.com', first_name: 'Ada', tagName: 'waitlist::kody' })
 * // => { subscriber: { id, email_address, ... }, tag: { id, name } }
 */
export async function subscribeAndTag(input) {
	const { tagName, tagId, ...subscriberInput } = input ?? {}
	if (!subscriberInput.email_address) throw new Error('subscribeAndTag: email_address is required')
	if (!tagName && !tagId) throw new Error('subscribeAndTag: tagName or tagId is required')
	const subscriber = await createSubscriber(subscriberInput)
	const tag = tagId ? { id: Number(tagId), name: undefined } : await ensureTag(/** @type {string} */ (tagName))
	await tagSubscriberByEmail(tag.id, subscriberInput.email_address)
	return { subscriber, tag }
}

/**
 * List forms (embeddable signup forms / landing pages).
 * @param {Record<string, string | number | boolean | null | undefined> & { maxItems?: number }} [query]
 */
export async function listForms(query = {}) {
	const { maxItems, ...rest } = query
	return listAll(getV4Forms, 'forms', rest, { maxItems })
}

/**
 * Add an existing subscriber to a form by email address.
 * @param {number | string} formId
 * @param {string} emailAddress
 * @param {{ referrer?: string }} [options]
 */
export async function addSubscriberToForm(formId, emailAddress, options = {}) {
	if (!formId) throw new Error('addSubscriberToForm: formId is required')
	if (!emailAddress) throw new Error('addSubscriberToForm: emailAddress is required')
	const body = await callParsed(
		postV4FormsFormIdSubscribers,
		{ params: { form_id: formId }, body: { email_address: emailAddress, ...options } },
		{ method: 'POST', path: `/v4/forms/${formId}/subscribers` },
	)
	const subscriber = /** @type {{ subscriber?: Record<string, unknown> }} */ (body).subscriber
	return subscriberSummary(subscriber)
}

/**
 * List segments (read-only in the v4 API).
 * @param {{ maxItems?: number }} [query]
 */
export async function listSegments(query = {}) {
	const { maxItems } = query
	return listAll(getV4Segments, 'segments', {}, { maxItems })
}