Skip to content
← Public packages

@kentcdodds/kit

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

src/broadcasts.js

168 lines · 5.7 KB · JavaScript
import {
	broadcastSummary,
	BWK_YOUTUBE_BROADCAST_DEFAULTS,
	callParsed,
	deleteV4BroadcastsId,
	getV4Broadcasts,
	getV4BroadcastsId,
	KitApiError,
	listAll,
	postV4Broadcasts,
	putV4BroadcastsId,
} from './kit-client.js'

/**
 * Create a broadcast **draft** — never sends or schedules. A human sends it
 * from the Kit UI via the returned `editUrl`.
 *
 * Only `subject` and `content` (HTML string) are required. `email_template_id`
 * is optional; when omitted Kit applies the account default template. Known
 * ids are exported as `KIT_TEMPLATES`.
 *
 * For **Better with Kent YouTube episode** emails, do **not** use this bare
 * helper — use `createBwkYoutubeBroadcastDraft` so sender, template, and mute
 * filter match prior BWK broadcasts.
 *
 * @param {Record<string, unknown>} input — Kit POST /v4/broadcasts body
 * @example
 * const draft = await createBroadcastDraft({ subject: 'Hello', content: '<p>Hi</p>' })
 * // => { id, subject, status: 'draft', editUrl: 'https://app.kit.com/campaigns/<id>/draft', ... }
 */
export async function createBroadcastDraft(input) {
	if (!input?.subject) throw new Error('createBroadcastDraft: subject is required')
	if (!input?.content) throw new Error('createBroadcastDraft: content is required')

	const body = await callParsed(
		postV4Broadcasts,
		{ body: input },
		{ method: 'POST', path: '/v4/broadcasts' },
	)
	const broadcast = /** @type {{ broadcast?: Record<string, unknown> }} */ (body).broadcast
	if (!broadcast?.id) {
		throw new KitApiError('Kit API returned no broadcast id after POST /broadcasts', {
			body,
			method: 'POST',
			path: '/broadcasts',
		})
	}
	return summarizeBroadcastDetail(broadcast)
}

/**
 * Create a Better with Kent YouTube early-access episode broadcast draft.
 *
 * Always applies `BWK_YOUTUBE_BROADCAST_DEFAULTS`:
 * - `email_address: hello@kentcdodds.com` (not kent@epicweb.dev / other senders)
 * - `email_template_id: KIT_TEMPLATES.betterWithKent` ("Better with Kent (opt-out link)")
 * - `subscriber_filter` excluding `KIT_MUTE_TAGS.betterWithKentYoutube`
 * - `public: false`
 *
 * Before creating, agents should still `getBroadcast` a recent completed BWK
 * email and match its pattern. Pass episode `subject`, `content`,
 * `preview_text`, `description`, and `thumbnail_url` in `input`.
 *
 * @param {Record<string, unknown>} input — episode fields; defaults win for
 *   sender/template/mute filter unless you intentionally override those keys
 * @example
 * const draft = await createBwkYoutubeBroadcastDraft({
 *   subject: 'Episode title',
 *   preview_text: 'Short preview',
 *   description: 'BWK 12-slug YouTube early access',
 *   thumbnail_url: 'https://i.ytimg.com/vi/VIDEO_ID/maxresdefault.jpg',
 *   content: '<p>Hey …</p>',
 * })
 */
export async function createBwkYoutubeBroadcastDraft(input) {
	if (!input?.subject) {
		throw new Error('createBwkYoutubeBroadcastDraft: subject is required')
	}
	if (!input?.content) {
		throw new Error('createBwkYoutubeBroadcastDraft: content is required')
	}
	// Defaults last so sender / template / mute filter cannot be dropped by mistake.
	return createBroadcastDraft({
		...input,
		...BWK_YOUTUBE_BROADCAST_DEFAULTS,
	})
}

/** @param {Record<string, unknown> | null | undefined} broadcast */
function summarizeBroadcastDetail(broadcast) {
	return {
		...broadcastSummary(broadcast),
		preview_text: broadcast?.preview_text,
		description: broadcast?.description,
		content: broadcast?.content,
		thumbnail_url: broadcast?.thumbnail_url,
		thumbnail_alt: broadcast?.thumbnail_alt,
		email_address: broadcast?.email_address,
		email_template: broadcast?.email_template,
		public: broadcast?.public,
		send_at: broadcast?.send_at,
		subscriber_filter: broadcast?.subscriber_filter,
		public_url: broadcast?.public_url,
	}
}

/**
 * Update a broadcast. Kit expects a full payload shape — merge from getBroadcast when unsure.
 * @param {number} id
 * @param {Record<string, unknown>} input
 */
export async function updateBroadcast(id, input) {
	if (!id) throw new Error('updateBroadcast: id is required')
	const body = await callParsed(
		putV4BroadcastsId,
		{ params: { id }, body: input },
		{ method: 'PUT', path: `/v4/broadcasts/${id}` },
	)
	const broadcast = /** @type {{ broadcast?: Record<string, unknown> }} */ (body).broadcast
	if (!broadcast?.id) {
		throw new KitApiError(`Kit API returned no broadcast id after PUT /broadcasts/${id}`, {
			body,
			method: 'PUT',
			path: `/broadcasts/${id}`,
		})
	}
	return summarizeBroadcastDetail(broadcast)
}

/** @param {number} id */
export async function getBroadcast(id) {
	if (!id) throw new Error('getBroadcast: id is required')
	const body = await callParsed(
		getV4BroadcastsId,
		{ params: { id } },
		{ method: 'GET', path: `/v4/broadcasts/${id}` },
	)
	const broadcast = /** @type {{ broadcast?: Record<string, unknown> }} */ (body).broadcast
	return summarizeBroadcastDetail(broadcast)
}

/**
 * List broadcast summaries, newest data first as returned by Kit.
 * Pass `maxItems` for exploratory reads — without it, this paginates the
 * entire broadcast history, which on large accounts can exhaust the execute
 * timeout.
 * @param {{ status?: string, per_page?: number, maxItems?: number }} [query]
 * @example
 * const recent = await listBroadcasts({ maxItems: 10 })
 */
export async function listBroadcasts(query = {}) {
	const { maxItems, ...rest } = query
	const broadcasts = await listAll(getV4Broadcasts, 'broadcasts', rest, { maxItems })
	return broadcasts
		.map((b) => broadcastSummary(/** @type {Record<string, unknown>} */ (b)))
		.filter(Boolean)
}

/** @param {number} id */
export async function deleteBroadcast(id) {
	if (!id) throw new Error('deleteBroadcast: id is required')
	await callParsed(
		deleteV4BroadcastsId,
		{ params: { id } },
		{ method: 'DELETE', path: `/v4/broadcasts/${id}` },
	)
	return { id, deleted: true }
}