← 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 })
}