Skip to content

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

Package listing

@kody/zendesk

src/articles.ts

308 lines · 9.8 KB · TypeScript
import { parseAuth } from './auth.ts'
import { zendeskRequest } from './client.ts'
import { compactDefined, extractListItems, mapArticle } from './models.ts'
import type {
	DryRunResult,
	JsonRecord,
	MutationInput,
	ZendeskArticle,
	ZendeskAuthInput,
	ZendeskPageInfo,
} from './types.ts'
import {
	clampInt,
	optionalBoolean,
	optionalNumber,
	optionalString,
	requireRecord,
	requireString,
} from './types.ts'

export type ListArticlesInput = ZendeskAuthInput & {
	perPage?: number
	page?: number
	locale?: string
	sectionId?: number
}

export type GetArticleInput = ZendeskAuthInput & {
	id: number | string
}

export type SearchArticlesInput = ZendeskAuthInput & {
	query: string
	locale?: string
	sectionId?: number
	perPage?: number
	page?: number
}

export type CreateArticleInput = ZendeskAuthInput &
	MutationInput & {
		title: string
		sectionId: number | string
		body?: string
		locale?: string
		draft?: boolean
		permissionGroupId?: number
		userSegmentId?: number | null
		labelNames?: Array<string>
	}

export type UpdateArticleInput = ZendeskAuthInput &
	MutationInput & {
		id: number | string
		title?: string
		body?: string
		locale?: string
		draft?: boolean
		permissionGroupId?: number
		userSegmentId?: number | null
		labelNames?: Array<string>
	}

export type ArticleListResult = {
	items: Array<ZendeskArticle>
	pageInfo: ZendeskPageInfo
}

function articleId(value: number | string, label = 'id'): string {
	if (typeof value === 'number' && Number.isInteger(value) && value > 0) return String(value)
	return requireString(value, label)
}

/**
 * List Zendesk Help Center articles.
 * @example
 * import { listArticles } from 'kody:@kody/zendesk/articles'
 * const { items } = await listArticles({ perPage: 10, subdomain: 'acme' })
 */
export async function listArticles(input: ListArticlesInput = {}): Promise<ArticleListResult> {
	const locale = optionalString(input.locale, 'locale')
	const path = locale
		? `/help_center/${encodeURIComponent(locale)}/articles`
		: '/help_center/articles'
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'articles.read',
		path,
		query: compactDefined({
			per_page: clampInt(input.perPage, 1, 100, 20),
			page: input.page === undefined ? undefined : clampInt(input.page, 1, 10_000, 1, 'page'),
			section: input.sectionId,
		}),
	})
	if ('dryRun' in result) throw new Error('listArticles is read-only.')
	return {
		items: extractListItems(result.data, ['articles'])
			.map(mapArticle)
			.filter((item): item is ZendeskArticle => Boolean(item)),
		pageInfo: result.pageInfo,
	}
}

/**
 * Get one Zendesk Help Center article by id.
 * @example
 * import { getArticle } from 'kody:@kody/zendesk/articles'
 * const article = await getArticle({ id, subdomain: 'acme' })
 */
export async function getArticle(input: GetArticleInput): Promise<ZendeskArticle> {
	const id = articleId(input.id)
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'articles.read',
		path: `/help_center/articles/${encodeURIComponent(id)}`,
	})
	if ('dryRun' in result) throw new Error('getArticle is read-only.')
	const mapped = mapArticle(result.data)
	if (!mapped) throw new Error('Zendesk did not return an article id.')
	return mapped
}

/**
 * Search Zendesk Help Center articles. GET search is a read.
 * @example
 * import { searchArticles } from 'kody:@kody/zendesk/articles'
 * const { items } = await searchArticles({ query: 'password reset', subdomain: 'acme' })
 */
export async function searchArticles(input: SearchArticlesInput): Promise<ArticleListResult> {
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'articles.read',
		path: '/help_center/articles/search',
		query: compactDefined({
			query: requireString(input.query, 'query'),
			locale: input.locale,
			section: input.sectionId,
			per_page: clampInt(input.perPage, 1, 100, 20),
			page: input.page === undefined ? undefined : clampInt(input.page, 1, 10_000, 1, 'page'),
		}),
	})
	if ('dryRun' in result) throw new Error('searchArticles is read-only.')
	return {
		items: extractListItems(result.data, ['results', 'articles'])
			.map(mapArticle)
			.filter((item): item is ZendeskArticle => Boolean(item)),
		pageInfo: result.pageInfo,
	}
}

function createArticleBody(input: CreateArticleInput): JsonRecord {
	return {
		article: compactDefined({
			title: requireString(input.title, 'title'),
			body: input.body,
			locale: input.locale ?? 'en-us',
			draft: input.draft,
			permission_group_id: input.permissionGroupId,
			user_segment_id: input.userSegmentId,
			label_names: input.labelNames,
		}),
	}
}

/**
 * Create a Help Center article. Requires `confirm: true`, or use `dryRun: true`.
 * @example
 * import { createArticle } from 'kody:@kody/zendesk/articles'
 * const preview = await createArticle({ title: 'Reset your password', sectionId: 1, dryRun: true, subdomain: 'acme' })
 */
export async function createArticle(
	input: CreateArticleInput,
): Promise<ZendeskArticle | DryRunResult> {
	const sectionId = articleId(input.sectionId, 'sectionId')
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'articles.write',
		method: 'POST',
		path: `/help_center/sections/${encodeURIComponent(sectionId)}/articles`,
		body: createArticleBody(input),
	})
	if ('dryRun' in result) return result
	const mapped = mapArticle(result.data)
	if (!mapped) throw new Error('Zendesk did not return a created article id.')
	return mapped
}

/**
 * Update a Help Center article. Requires `confirm: true`, or use `dryRun: true`.
 * When `locale` plus title/body is set, updates that translation.
 * @example
 * import { updateArticle } from 'kody:@kody/zendesk/articles'
 * const preview = await updateArticle({ id, title: 'Updated title', dryRun: true, subdomain: 'acme' })
 */
export async function updateArticle(
	input: UpdateArticleInput,
): Promise<ZendeskArticle | DryRunResult> {
	const id = articleId(input.id)
	const locale = optionalString(input.locale, 'locale')
	const translationUpdate = Boolean(locale && (input.title || input.body || input.draft !== undefined))
	const path = translationUpdate
		? `/help_center/articles/${encodeURIComponent(id)}/translations/${encodeURIComponent(locale as string)}`
		: `/help_center/articles/${encodeURIComponent(id)}`
	const body = translationUpdate
		? {
				translation: compactDefined({
					title: input.title,
					body: input.body,
					draft: input.draft,
				}),
			}
		: {
				article: compactDefined({
					title: input.title,
					draft: input.draft,
					permission_group_id: input.permissionGroupId,
					user_segment_id: input.userSegmentId,
					label_names: input.labelNames,
				}),
			}
	const result = await zendeskRequest<unknown>({
		...input,
		operation: 'articles.write',
		method: 'PUT',
		path,
		body,
	})
	if ('dryRun' in result) return result
	const mapped = mapArticle(result.data)
	if (!mapped) throw new Error('Zendesk did not return an updated article id.')
	return mapped
}

export function parseListArticles(params: Record<string, unknown>): ListArticlesInput {
	const input = requireRecord(params, 'list-articles')
	return {
		...parseAuth(input),
		perPage: input.perPage as number | undefined,
		page: input.page as number | undefined,
		locale: optionalString(input.locale, 'locale'),
		sectionId: optionalNumber(input.sectionId, 'sectionId'),
	}
}

export function parseGetArticle(params: Record<string, unknown>): GetArticleInput {
	const input = requireRecord(params, 'get-article')
	return { ...parseAuth(input), id: input.id as number | string }
}

export function parseSearchArticles(params: Record<string, unknown>): SearchArticlesInput {
	const input = requireRecord(params, 'search-articles')
	return {
		...parseAuth(input),
		query: requireString(input.query, 'query'),
		locale: optionalString(input.locale, 'locale'),
		sectionId: optionalNumber(input.sectionId, 'sectionId'),
		perPage: input.perPage as number | undefined,
		page: input.page as number | undefined,
	}
}

export function parseCreateArticle(params: Record<string, unknown>): CreateArticleInput {
	const input = requireRecord(params, 'create-article')
	return {
		...parseAuth(input),
		title: requireString(input.title, 'title'),
		sectionId: input.sectionId as number | string,
		body: optionalString(input.body, 'body'),
		locale: optionalString(input.locale, 'locale'),
		draft: optionalBoolean(input.draft, 'draft'),
		permissionGroupId: optionalNumber(input.permissionGroupId, 'permissionGroupId'),
		userSegmentId: optionalNumber(input.userSegmentId, 'userSegmentId'),
		labelNames: Array.isArray(input.labelNames)
			? input.labelNames.map((name) => requireString(name, 'labelNames[]'))
			: undefined,
		confirm: optionalBoolean(input.confirm, 'confirm'),
		dryRun: optionalBoolean(input.dryRun, 'dryRun'),
	}
}

export function parseUpdateArticle(params: Record<string, unknown>): UpdateArticleInput {
	const input = requireRecord(params, 'update-article')
	return {
		...parseAuth(input),
		id: input.id as number | string,
		title: optionalString(input.title, 'title'),
		body: optionalString(input.body, 'body'),
		locale: optionalString(input.locale, 'locale'),
		draft: optionalBoolean(input.draft, 'draft'),
		permissionGroupId: optionalNumber(input.permissionGroupId, 'permissionGroupId'),
		userSegmentId: optionalNumber(input.userSegmentId, 'userSegmentId'),
		labelNames: Array.isArray(input.labelNames)
			? input.labelNames.map((name) => requireString(name, 'labelNames[]'))
			: undefined,
		confirm: optionalBoolean(input.confirm, 'confirm'),
		dryRun: optionalBoolean(input.dryRun, 'dryRun'),
	}
}

/**
 * Zendesk article helpers. Writes require `confirm: true` or `dryRun: true`.
 * @example
 * import { listArticles } from 'kody:@kody/zendesk/articles'
 * const { items } = await listArticles({ perPage: 10, subdomain: 'acme' })
 */
export default async function articlesEntrypoint(params: Record<string, unknown> = {}) {
	return listArticles(parseListArticles(params))
}