Skip to content
← Public packages

@kentcdodds/kit

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

src/kit-client.js

321 lines · 8.9 KB · JavaScript
/**
 * Domain helpers over the OpenAPI-scaffolded Kit client.
 * Transport: ./openapi-client.js (headerSecret kitApiKey → X-Kit-Api-Key).
 */
import {
	deleteV4BroadcastsId,
	deleteV4BulkTags,
	deleteV4TagsTagIdSubscribersId,
	getV4Account,
	getV4AccountEmailStats,
	getV4AccountGrowthStats,
	getV4Broadcasts,
	getV4BroadcastsBroadcastIdStats,
	getV4BroadcastsId,
	getV4BroadcastsStats,
	getV4Forms,
	getV4Segments,
	getV4Sequences,
	getV4SequencesId,
	getV4SequencesSequenceIdEmails,
	getV4SequencesSequenceIdEmailsId,
	getV4Subscribers,
	getV4SubscribersId,
	getV4SubscribersSubscriberIdTags,
	getV4Tags,
	getV4TagsTagIdSubscribers,
	postV4Broadcasts,
	postV4FormsFormIdSubscribers,
	postV4Sequences,
	postV4SequencesSequenceIdEmails,
	postV4SequencesSequenceIdSubscribers,
	postV4Subscribers,
	postV4SubscribersIdUnsubscribe,
	postV4Tags,
	postV4TagsTagIdSubscribers,
	putV4BroadcastsId,
	putV4SequencesSequenceIdEmailsId,
	putV4SubscribersId,
} from './openapi-client.js'

/** @typedef {Record<string, string | number | boolean | null | undefined>} KitQuery */
/** @typedef {{ params?: Record<string, unknown>, query?: KitQuery, headers?: Record<string, string>, body?: unknown }} ScaffoldInput */
/** @typedef {{ fetchImpl?: typeof fetch }} ScaffoldOptions */

export const KIT_API_BASE_URL = 'https://api.kit.com/v4'
export const KIT_SECRET_NAME = 'kitApiKey'

/** Known email template ids on Kent's Kit account. Verify in Kit before relying on new ids. */
export const KIT_TEMPLATES = {
	textOnly: 684651,
	epePodcast: 5215129,
	/** "Better with Kent (opt-out link)" — required for BWK YouTube episode emails. */
	betterWithKent: 5341436,
}

/**
 * Known mute tags used in broadcast `subscriber_filter` `none` clauses.
 * Always exclude the matching mute tag for series emails so opted-out people
 * are not re-mailed.
 */
export const KIT_MUTE_TAGS = {
	/** `mute: Better with Kent YouTube emails` */
	betterWithKentYoutube: 20860981,
}

/**
 * Required Kit fields for Better with Kent YouTube early-access episode emails.
 * Match prior completed BWK broadcasts (08–10+). Prefer
 * `createBwkYoutubeBroadcastDraft` so these cannot be omitted by accident.
 */
export const BWK_YOUTUBE_BROADCAST_DEFAULTS = {
	email_address: 'hello@kentcdodds.com',
	email_template_id: KIT_TEMPLATES.betterWithKent,
	public: false,
	subscriber_filter: [
		{
			none: [
				{
					type: 'tag',
					ids: [KIT_MUTE_TAGS.betterWithKentYoutube],
				},
			],
		},
	],
}

/** EPE podcast sequence — packages/bepe/kit-sequence/sequence.json */
export const EPE_SEQUENCE_ID = 2757781

export class KitApiError extends Error {
	/**
	 * @param {string} message
	 * @param {{ status?: number, body?: unknown, method?: string, path?: string }} [meta]
	 */
	constructor(message, meta = {}) {
		super(message)
		this.name = 'KitApiError'
		this.status = meta.status
		this.body = meta.body
		this.method = meta.method
		this.path = meta.path
	}
}

/**
 * @param {Response} response
 * @param {{ method?: string, path?: string }} [meta]
 */
export async function parseOkJson(response, meta = {}) {
	const text = await response.text()
	let parsed
	try {
		parsed = text ? JSON.parse(text) : null
	} catch {
		parsed = text
	}

	if (!response.ok) {
		const message =
			typeof parsed === 'object' &&
			parsed &&
			Array.isArray(/** @type {{ errors?: string[] }} */ (parsed).errors)
				? /** @type {{ errors: string[] }} */ (parsed).errors.join('; ')
				: `Kit API ${response.status} ${meta.method ?? response.statusText} ${meta.path ?? ''}`
		throw new KitApiError(message, {
			status: response.status,
			body: parsed,
			method: meta.method,
			path: meta.path,
		})
	}

	return parsed
}

/**
 * @param {(input?: ScaffoldInput, options?: ScaffoldOptions) => Promise<Response>} fn
 * @param {ScaffoldInput} [input]
 * @param {{ method?: string, path?: string }} [meta]
 */
export async function callParsed(fn, input = {}, meta = {}) {
	return parseOkJson(await fn(input), meta)
}

/**
 * Cursor-paginate a list endpoint. Without `maxItems` this walks every page,
 * which on large accounts can exhaust the execute timeout — prefer passing
 * `maxItems` for exploratory reads.
 * @param {(input?: ScaffoldInput) => Promise<Response>} fn
 * @param {string} itemKey
 * @param {KitQuery} [query]
 * @param {{ maxItems?: number }} [options]
 */
export async function listAll(fn, itemKey, query = {}, options = {}) {
	const maxItems = options.maxItems
	/** @type {unknown[]} */
	const items = []
	let after
	for (;;) {
		const perPage = maxItems ? Math.min(100, maxItems - items.length) : 100
		const page = await callParsed(
			fn,
			{ query: { per_page: perPage, ...query, ...(after ? { after } : {}) } },
			{ method: 'GET', path: itemKey },
		)
		const record = /** @type {Record<string, unknown>} */ (page)
		const chunk = record[itemKey]
		if (Array.isArray(chunk)) items.push(...chunk)
		if (maxItems && items.length >= maxItems) return items.slice(0, maxItems)
		const pagination = /** @type {{ has_next_page?: boolean, end_cursor?: string } | undefined} */ (
			record.pagination
		)
		if (!pagination?.has_next_page || !pagination.end_cursor) break
		after = pagination.end_cursor
	}
	return items
}

/** @param {{ id?: number, subject?: string, status?: string }} broadcast */
export function broadcastSummary(broadcast) {
	if (!broadcast?.id) return null
	return {
		id: broadcast.id,
		subject: broadcast.subject,
		status: broadcast.status,
		editUrl: `https://app.kit.com/campaigns/${broadcast.id}/draft`,
	}
}

/** @param {{ id?: number, email_address?: string, first_name?: string, state?: string, created_at?: string }} subscriber */
export function subscriberSummary(subscriber) {
	if (!subscriber?.id) return null
	return {
		id: subscriber.id,
		email_address: subscriber.email_address,
		first_name: subscriber.first_name,
		state: subscriber.state,
		created_at: subscriber.created_at,
	}
}

/** @param {{ id?: number, sequence_id?: number, subject?: string, published?: boolean }} email */
export function sequenceEmailSummary(email) {
	if (!email?.id) return null
	return {
		id: email.id,
		sequenceId: email.sequence_id,
		subject: email.subject,
		published: email.published,
	}
}

/** GET /v4/account */
export async function getCurrentAccount(query = {}) {
	return callParsed(getV4Account, { query }, { method: 'GET', path: '/v4/account' })
}

/** GET /v4/account/email_stats */
export async function getAccountEmailStats(query = {}) {
	return callParsed(
		getV4AccountEmailStats,
		{ query },
		{ method: 'GET', path: '/v4/account/email_stats' },
	)
}

/** GET /v4/account/growth_stats */
export async function getAccountGrowthStats(query = {}) {
	return callParsed(
		getV4AccountGrowthStats,
		{ query },
		{ method: 'GET', path: '/v4/account/growth_stats' },
	)
}

/** GET /v4/broadcasts/{broadcast_id}/stats */
export async function getBroadcastStats(broadcastId, query = {}) {
	if (!broadcastId) throw new Error('getBroadcastStats: broadcastId is required')
	return callParsed(
		getV4BroadcastsBroadcastIdStats,
		{ params: { broadcast_id: broadcastId }, query },
		{ method: 'GET', path: `/v4/broadcasts/${broadcastId}/stats` },
	)
}

/** GET /v4/broadcasts/stats */
export async function getBroadcastsStats(query = {}) {
	return callParsed(
		getV4BroadcastsStats,
		{ query },
		{ method: 'GET', path: '/v4/broadcasts/stats' },
	)
}

/**
 * GET /v4/sequences
 * @param {KitQuery & { maxItems?: number }} [query] — `maxItems` caps pagination.
 */
export async function listSequences(query = {}) {
	const { maxItems, ...rest } = query
	return listAll(getV4Sequences, 'sequences', rest, { maxItems })
}

/** GET /v4/sequences/{id} */
export async function getSequence(id, query = {}) {
	if (!id) throw new Error('getSequence: id is required')
	return callParsed(
		getV4SequencesId,
		{ params: { id }, query },
		{ method: 'GET', path: `/v4/sequences/${id}` },
	)
}

/**
 * GET /v4/sequences/{sequence_id}/emails
 * @param {number | string} sequenceId
 * @param {KitQuery & { maxItems?: number }} [query] — `maxItems` caps pagination.
 */
export async function listSequenceEmails(sequenceId, query = {}) {
	if (!sequenceId) throw new Error('listSequenceEmails: sequenceId is required')
	const { maxItems, ...rest } = query
	return listAll(
		(input) =>
			getV4SequencesSequenceIdEmails({
				...input,
				params: { sequence_id: sequenceId, ...(input?.params ?? {}) },
			}),
		'emails',
		rest,
		{ maxItems },
	)
}

// Re-export scaffold ops used by domain modules
export {
	deleteV4BroadcastsId,
	deleteV4BulkTags,
	deleteV4TagsTagIdSubscribersId,
	getV4Broadcasts,
	getV4BroadcastsId,
	getV4Forms,
	getV4Segments,
	getV4SequencesSequenceIdEmailsId,
	getV4Subscribers,
	getV4SubscribersId,
	getV4SubscribersSubscriberIdTags,
	getV4Tags,
	getV4TagsTagIdSubscribers,
	postV4Broadcasts,
	postV4FormsFormIdSubscribers,
	postV4Sequences,
	postV4SequencesSequenceIdEmails,
	postV4SequencesSequenceIdSubscribers,
	postV4Subscribers,
	postV4SubscribersIdUnsubscribe,
	postV4Tags,
	postV4TagsTagIdSubscribers,
	putV4BroadcastsId,
	putV4SequencesSequenceIdEmailsId,
	putV4SubscribersId,
}